diff --git a/locale/Messages.resx b/locale/Messages.resx index 5e24811..a9367f7 100644 --- a/locale/Messages.resx +++ b/locale/Messages.resx @@ -570,6 +570,12 @@ Cleared {0} messages from {1} + + An error occurred during guild data load. + + + This will lead to unexpected behavior. Data will no longer be saved + An error occurred during command execution, try again later. diff --git a/locale/Messages.ru.resx b/locale/Messages.ru.resx index 1cc1f9e..d0cbd79 100644 --- a/locale/Messages.ru.resx +++ b/locale/Messages.ru.resx @@ -570,6 +570,12 @@ Очищено {0} сообщений от {1} + + Произошла ошибка при загрузке данных сервера. + + + Это может привести к неожиданному поведению. Данные больше не будут сохраняться. + Произошла ошибка при выполнении команды, повтори попытку позже. diff --git a/locale/Messages.tt-ru.resx b/locale/Messages.tt-ru.resx index 48ad8e7..3bed232 100644 --- a/locale/Messages.tt-ru.resx +++ b/locale/Messages.tt-ru.resx @@ -570,6 +570,12 @@ вырезано {0} забавных сообщений от {1} + + произошёл тотальный разнос в гилддате. + + + возможно всё съедет с крыши, но знай, что я больше ничё не сохраню. + произошёл тотальный разнос в команде, удачи. diff --git a/src/Data/GuildData.cs b/src/Data/GuildData.cs index a675037..5a903d6 100644 --- a/src/Data/GuildData.cs +++ b/src/Data/GuildData.cs @@ -17,10 +17,12 @@ public sealed class GuildData public readonly JsonNode Settings; public readonly string SettingsPath; + public readonly bool DataLoadFailed; + public GuildData( JsonNode settings, string settingsPath, Dictionary scheduledEvents, string scheduledEventsPath, - Dictionary memberData, string memberDataPath) + Dictionary memberData, string memberDataPath, bool dataLoadFailed) { Settings = settings; SettingsPath = settingsPath; @@ -28,6 +30,7 @@ public sealed class GuildData ScheduledEventsPath = scheduledEventsPath; MemberData = memberData; MemberDataPath = memberDataPath; + DataLoadFailed = dataLoadFailed; } public MemberData GetOrCreateMemberData(Snowflake memberId) diff --git a/src/Messages.Designer.cs b/src/Messages.Designer.cs index 5f38061..e2184d7 100644 --- a/src/Messages.Designer.cs +++ b/src/Messages.Designer.cs @@ -997,6 +997,22 @@ namespace Octobot { } } + internal static string DataLoadFailedTitle + { + get + { + return ResourceManager.GetString("DataLoadFailedTitle", resourceCulture); + } + } + + internal static string DataLoadFailedDescription + { + get + { + return ResourceManager.GetString("DataLoadFailedDescription", resourceCulture); + } + } + internal static string CommandExecutionFailed { get @@ -1004,7 +1020,7 @@ namespace Octobot { return ResourceManager.GetString("CommandExecutionFailed", resourceCulture); } } - + internal static string ContactDevelopers { get diff --git a/src/Responders/GuildLoadedResponder.cs b/src/Responders/GuildLoadedResponder.cs index 78dcc43..3e08060 100644 --- a/src/Responders/GuildLoadedResponder.cs +++ b/src/Responders/GuildLoadedResponder.cs @@ -8,6 +8,7 @@ using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Gateway.Events; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; +using Remora.Rest.Core; using Remora.Results; namespace Octobot.Responders; @@ -42,7 +43,6 @@ public class GuildLoadedResponder : IResponder } var guild = gatewayEvent.Guild.AsT0; - _logger.LogInformation("Joined guild {ID} (\"{Name}\")", guild.ID, guild.Name); var data = await _guildData.GetData(guild.ID, ct); var cfg = data.Settings; @@ -51,6 +51,32 @@ public class GuildLoadedResponder : IResponder data.GetOrCreateMemberData(member.User.Value.ID); } + var botResult = await _userApi.GetCurrentUserAsync(ct); + if (!botResult.IsDefined(out var bot)) + { + return Result.FromError(botResult); + } + + if (data.DataLoadFailed) + { + var errorEmbed = new EmbedBuilder() + .WithSmallTitle(Messages.DataLoadFailedTitle, bot) + .WithDescription(Messages.DataLoadFailedDescription) + .WithFooter(Messages.ContactDevelopers) + .WithColour(ColorsList.Red) + .Build(); + + if (!errorEmbed.IsDefined(out var errorBuilt)) + { + return Result.FromError(errorEmbed); + } + + return (Result)await _channelApi.CreateMessageAsync( + GetEmergencyFeedbackChannel(guild, data), embeds: new[] { errorBuilt }, ct: ct); + } + + _logger.LogInformation("Loaded guild {ID} (\"{Name}\")", guild.ID, guild.Name); + if (!GuildSettings.ReceiveStartupMessages.Get(cfg)) { return Result.FromSuccess(); @@ -61,12 +87,6 @@ public class GuildLoadedResponder : IResponder return Result.FromSuccess(); } - var botResult = await _userApi.GetCurrentUserAsync(ct); - if (!botResult.IsDefined(out var bot)) - { - return Result.FromError(botResult); - } - Messages.Culture = GuildSettings.Language.Get(cfg); var i = Random.Shared.Next(1, 4); @@ -84,4 +104,23 @@ public class GuildLoadedResponder : IResponder return (Result)await _channelApi.CreateMessageAsync( GuildSettings.PrivateFeedbackChannel.Get(cfg), embeds: new[] { built }, ct: ct); } + + private static Snowflake GetEmergencyFeedbackChannel(IGuildCreate.IAvailableGuild guild, GuildData data) + { + var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings); + if (!privateFeedback.Empty()) + { + return privateFeedback; + } + + var publicFeedback = GuildSettings.PublicFeedbackChannel.Get(data.Settings); + if (!publicFeedback.Empty()) + { + return publicFeedback; + } + + return guild.SystemChannelID.AsOptional().IsDefined(out var systemChannel) + ? systemChannel + : guild.Channels[0].ID; + } } diff --git a/src/Responders/GuildUnloadedResponder.cs b/src/Responders/GuildUnloadedResponder.cs index 0cffe25..47bde75 100644 --- a/src/Responders/GuildUnloadedResponder.cs +++ b/src/Responders/GuildUnloadedResponder.cs @@ -30,7 +30,7 @@ public class GuildUnloadedResponder : IResponder var isDataRemoved = _guildData.UnloadGuildData(guildId); if (isDataRemoved) { - _logger.LogInformation("Left guild {GuildId}", guildId); + _logger.LogInformation("Unloaded guild {GuildId}", guildId); } return Task.FromResult(Result.FromSuccess()); diff --git a/src/Services/GuildDataService.cs b/src/Services/GuildDataService.cs index 73d4a25..78203b5 100644 --- a/src/Services/GuildDataService.cs +++ b/src/Services/GuildDataService.cs @@ -43,25 +43,31 @@ public sealed class GuildDataService : IHostedService { var tasks = new List(); var datas = _datas.Values.ToArray(); - foreach (var data in datas) + foreach (var data in datas.Where(data => !data.DataLoadFailed)) { - await using var settingsStream = File.Create(data.SettingsPath); - tasks.Add(JsonSerializer.SerializeAsync(settingsStream, data.Settings, cancellationToken: ct)); - - await using var eventsStream = File.Create(data.ScheduledEventsPath); - tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct)); + tasks.Add(SerializeObjectSafelyAsync(data.Settings, data.SettingsPath, ct)); + tasks.Add(SerializeObjectSafelyAsync(data.ScheduledEvents, data.ScheduledEventsPath, ct)); var memberDatas = data.MemberData.Values.ToArray(); - foreach (var memberData in memberDatas) - { - await using var memberDataStream = File.Create($"{data.MemberDataPath}/{memberData.Id}.json"); - tasks.Add(JsonSerializer.SerializeAsync(memberDataStream, memberData, cancellationToken: ct)); - } + tasks.AddRange(memberDatas.Select(memberData => + SerializeObjectSafelyAsync(memberData, $"{data.MemberDataPath}/{memberData.Id}.json", ct))); } await Task.WhenAll(tasks); } + private static async Task SerializeObjectSafelyAsync(T obj, string path, CancellationToken ct) + { + var tempFilePath = path + ".tmp"; + await using (var tempFileStream = File.Create(tempFilePath)) + { + await JsonSerializer.SerializeAsync(tempFileStream, obj, cancellationToken: ct); + } + + File.Copy(tempFilePath, path, true); + File.Delete(tempFilePath); + } + public async Task GetData(Snowflake guildId, CancellationToken ct = default) { return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct); @@ -88,20 +94,50 @@ public sealed class GuildDataService : IHostedService await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct); } + var dataLoadFailed = false; + await using var settingsStream = File.OpenRead(settingsPath); - var jsonSettings - = JsonNode.Parse(settingsStream); + JsonNode? jsonSettings = null; + try + { + jsonSettings = JsonNode.Parse(settingsStream); + } + catch (Exception e) + { + _logger.LogError(e, "Guild settings load failed: {Path}", settingsPath); + dataLoadFailed = true; + } await using var eventsStream = File.OpenRead(scheduledEventsPath); - var events - = await JsonSerializer.DeserializeAsync>( + Dictionary? events = null; + try + { + events = await JsonSerializer.DeserializeAsync>( eventsStream, cancellationToken: ct); + } + catch (Exception e) + { + _logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath); + dataLoadFailed = true; + } var memberData = new Dictionary(); foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles()) { await using var dataStream = dataFileInfo.OpenRead(); - var data = await JsonSerializer.DeserializeAsync(dataStream, cancellationToken: ct); + MemberData? data; + try + { + data = await JsonSerializer.DeserializeAsync(dataStream, cancellationToken: ct); + } + catch (Exception e) + { + _logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath, + dataFileInfo.Name); + dataLoadFailed = true; + continue; + } + if (data is null) { continue; @@ -113,7 +149,8 @@ public sealed class GuildDataService : IHostedService var finalData = new GuildData( jsonSettings ?? new JsonObject(), settingsPath, events ?? new Dictionary(), scheduledEventsPath, - memberData, memberDataPath); + memberData, memberDataPath, + dataLoadFailed); _datas.TryAdd(guildId, finalData); @@ -129,7 +166,8 @@ public sealed class GuildDataService : IHostedService Directory.CreateDirectory($"{newPath}/.."); Directory.Move(oldPath, newPath); - _logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath, newPath); + _logger.LogInformation("Moved guild data to separate folder: \"{OldPath}\" -> \"{NewPath}\"", oldPath, + newPath); } }