diff --git a/.gitignore b/.gitignore index 0dbcbd8..1ce728a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,5 @@ Thumbs.db *.dotCover #secret config -karma.cfg.json -*secrets.ini -*.secret.json +secrets.json +secrets.*.json diff --git a/AntiAntiSwearingBot.Tests/DetectTests.cs b/AntiAntiSwearingBot.Tests/DetectTests.cs index da19756..7ac0d57 100644 --- a/AntiAntiSwearingBot.Tests/DetectTests.cs +++ b/AntiAntiSwearingBot.Tests/DetectTests.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using System.Text; + +using Microsoft.Extensions.Options; + using Xunit; namespace AntiAntiSwearingBot.Tests @@ -8,13 +11,12 @@ namespace AntiAntiSwearingBot.Tests public class DetectTests { Unbleeper ubl { get; } - Config cfg { get; } SearchDictionary dict { get; } public DetectTests() { - cfg = Config.Load("aasb.cfg.json"); - dict = new SearchDictionary(cfg); + + dict = new SearchDictionary(OptionsMonitor ); ubl = new Unbleeper(dict, cfg.Unbleeper); } diff --git a/AntiAntiSwearingBot/AntiAntiSwearingBot.cs b/AntiAntiSwearingBot/Aasb.cs similarity index 61% rename from AntiAntiSwearingBot/AntiAntiSwearingBot.cs rename to AntiAntiSwearingBot/Aasb.cs index 9b8bf89..1d96d27 100644 --- a/AntiAntiSwearingBot/AntiAntiSwearingBot.cs +++ b/AntiAntiSwearingBot/Aasb.cs @@ -1,42 +1,43 @@ -using System.Text.RegularExpressions; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Telegram.Bot; -using Telegram.Bot.Exceptions; using Telegram.Bot.Extensions.Polling; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; using AntiAntiSwearingBot.Commands; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace AntiAntiSwearingBot; -public class AntiAntiSwearingBot +public class Aasb : IHostedService { - Config Config { get; } SearchDictionary Dict { get; } Unbleeper Unbleeper { get; } + ILogger Log { get; } + public bool Started { get; private set; } = false; - public AntiAntiSwearingBot(Config cfg, SearchDictionary dict) + public Aasb(ILogger log, IOptions tgCfg, Unbleeper unbp) { - Config = cfg; - Dict = dict; - Unbleeper = new Unbleeper(dict, cfg.Unbleeper); + Log = log; + TelegramSettings = tgCfg.Value; + Unbleeper = unbp; } + TelegramSettings TelegramSettings { get; } TelegramBotClient TelegramBot { get; set; } ChatCommandRouter Router { get; set; } public User Me { get; private set; } - public async Task Init() + public async Task StartAsync(CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(Config.ApiKey)) + if (string.IsNullOrWhiteSpace(TelegramSettings.ApiKey)) return; - TelegramBot = new TelegramBotClient(Config.ApiKey); + TelegramBot = new TelegramBotClient(TelegramSettings.ApiKey); Me = await TelegramBot.GetMeAsync(); - - + Log.LogInformation("Connected to Telegram as @{Username}", Me.Username); Router = new ChatCommandRouter(Me.Username); Router.Add(new LearnCommand(Dict), "learn"); Router.Add(new UnlearnCommand(Dict), "unlearn"); @@ -46,16 +47,19 @@ public class AntiAntiSwearingBot HandleUpdateAsync, HandleErrorAsync, receiverOptions); + + Log.LogInformation("AntiAntiSwearBot started!"); + Started = true; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await TelegramBot.CloseAsync(); } Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, CancellationToken cancellationToken) { - var ErrorMessage = exception switch - { - ApiRequestException apiRequestException => $"Telegram API Error:\n[{apiRequestException.ErrorCode}]\n{apiRequestException.Message}", - _ => exception.ToString() - }; - Console.WriteLine(ErrorMessage); + Log.LogError(exception, "Exception while handling API message"); return Task.CompletedTask; } @@ -76,7 +80,8 @@ public class AntiAntiSwearingBot await TelegramBot.SendTextMessageAsync( msg.Chat.Id, commandResponse, - replyToMessageId: msg.MessageId); + replyToMessageId: msg.MessageId, + disableNotification: true); } else { @@ -85,13 +90,13 @@ public class AntiAntiSwearingBot await TelegramBot.SendTextMessageAsync( msg.Chat.Id, unbleepResponse, - replyToMessageId: msg.MessageId); + replyToMessageId: msg.MessageId, + disableNotification: true); } - } catch (Exception e) { - Console.WriteLine(e); + Log.LogError(e, "Exception while handling message {0}", msg); } } } diff --git a/AntiAntiSwearingBot/AntiAntiSwearingBot.csproj b/AntiAntiSwearingBot/AntiAntiSwearingBot.csproj index bb3cea5..dec9685 100644 --- a/AntiAntiSwearingBot/AntiAntiSwearingBot.csproj +++ b/AntiAntiSwearingBot/AntiAntiSwearingBot.csproj @@ -1,8 +1,8 @@ - + - Exe net6.0 + en @@ -10,10 +10,18 @@ - + + + Always + + + Always + + + Always diff --git a/AntiAntiSwearingBot/Config.cs b/AntiAntiSwearingBot/Config.cs index e9bbf07..86aa3a2 100644 --- a/AntiAntiSwearingBot/Config.cs +++ b/AntiAntiSwearingBot/Config.cs @@ -1,29 +1,28 @@ namespace AntiAntiSwearingBot; -public class Config : ConfigBase +public class ServiceSettings { - public string ApiKey { get; private set; } - public ProxySettings Proxy { get; private set; } - public SearchDictionarySettings SearchDictionary { get; private set; } - public UnbleeperSettings Unbleeper { get; private set; } + public string Urls { get; set; } } -public struct UnbleeperSettings +public class UnbleeperSettings { - public string BleepedSwearsRegex { get; private set; } - public int MinAmbiguousWordLength { get; private set; } - public int MinWordLength { get; private set; } + public string BleepedSwearsRegex { get; set; } + public int MinAmbiguousWordLength { get; set; } + public int MinWordLength { get; set; } } -public struct SearchDictionarySettings +public class SearchDictionarySettings { - public string DictionaryPath { get; private set; } + public string DictionaryPath { get; set; } } -public struct ProxySettings +public class TelegramSettings { - public string Url { get; private set; } - public int Port { get; private set; } - public string Login { get; private set; } - public string Password { get; private set; } + public string ApiKey { get; set; } + public bool UseProxy { get; set; } + public string Url { get; set; } + public int Port { get; set; } + public string Login { get; set; } + public string Password { get; set; } } diff --git a/AntiAntiSwearingBot/ConfigBase.cs b/AntiAntiSwearingBot/ConfigBase.cs deleted file mode 100644 index 58b6c95..0000000 --- a/AntiAntiSwearingBot/ConfigBase.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.IO; -using System.Reflection; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; - -namespace AntiAntiSwearingBot; - -public abstract class ConfigBase -{ - public static T Load(params string[] paths) where T : ConfigBase, new() - { - var result = new T(); - var configJson = new JObject(); - var mergeSettings = new JsonMergeSettings - { - MergeArrayHandling = MergeArrayHandling.Union - }; - - foreach (var path in paths) - { - try { configJson.Merge(JObject.Parse(File.ReadAllText(path)), mergeSettings);} - catch { } - } - - using (var sr = configJson.CreateReader()) - { - var settings = new JsonSerializerSettings - { - ContractResolver = new PrivateSetterContractResolver() - }; - JsonSerializer.CreateDefault(settings).Populate(sr, result); - } - - return result; - } -} - -public class PrivateSetterContractResolver : DefaultContractResolver -{ - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) - { - var jProperty = base.CreateProperty(member, memberSerialization); - if (jProperty.Writable) - return jProperty; - - jProperty.Writable = member.IsPropertyWithSetter(); - - return jProperty; - } -} - -public class PrivateSetterCamelCasePropertyNamesContractResolver : CamelCasePropertyNamesContractResolver -{ - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) - { - var jProperty = base.CreateProperty(member, memberSerialization); - if (jProperty.Writable) - return jProperty; - - jProperty.Writable = member.IsPropertyWithSetter(); - - return jProperty; - } -} - -internal static class MemberInfoExtensions -{ - internal static bool IsPropertyWithSetter(this MemberInfo member) - { - var property = member as PropertyInfo; - - return property?.GetSetMethod(true) != null; - } -} diff --git a/AntiAntiSwearingBot/Extensions/IServiceCollectionExtensions.cs b/AntiAntiSwearingBot/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..4b416b9 --- /dev/null +++ b/AntiAntiSwearingBot/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace AntiAntiSwearingBot; +public static class IServiceCollectionExtensions +{ + public static IServiceCollection AddHostedSingleton(this IServiceCollection isc) where TService : class, IHostedService + { + return isc.AddSingleton().AddHostedService(svc => svc.GetRequiredService()); + } +} diff --git a/AntiAntiSwearingBot/Extensions/ReaderWriterLockSlimExtensions.cs b/AntiAntiSwearingBot/Extensions/ReaderWriterLockSlimExtensions.cs new file mode 100644 index 0000000..7c4d57b --- /dev/null +++ b/AntiAntiSwearingBot/Extensions/ReaderWriterLockSlimExtensions.cs @@ -0,0 +1,23 @@ +using System.Threading; + +namespace AntiAntiSwearingBot; + +public readonly ref struct ReadLockToken +{ + ReaderWriterLockSlim Lock { get; } + public ReadLockToken(ReaderWriterLockSlim l) => (Lock = l).EnterReadLock(); + public void Dispose() => Lock.ExitReadLock(); +} + +public readonly ref struct WriteLockToken +{ + ReaderWriterLockSlim Lock { get; } + public WriteLockToken(ReaderWriterLockSlim l) => (Lock = l).EnterWriteLock(); + public void Dispose() => Lock.ExitWriteLock(); +} + +public static class ReaderWriterLockSlimExtensions +{ + public static ReadLockToken GetReadLockToken(this ReaderWriterLockSlim l) => new ReadLockToken(l); + public static WriteLockToken GetWriteLockToken(this ReaderWriterLockSlim l) => new WriteLockToken(l); +} diff --git a/AntiAntiSwearingBot/GlobalUsings.cs b/AntiAntiSwearingBot/GlobalUsings.cs index e5252f6..e570036 100644 --- a/AntiAntiSwearingBot/GlobalUsings.cs +++ b/AntiAntiSwearingBot/GlobalUsings.cs @@ -3,3 +3,4 @@ global using System.Text; global using System.Linq; global using System.Collections.Generic; +global using Microsoft.Extensions.Options; \ No newline at end of file diff --git a/AntiAntiSwearingBot/Program.cs b/AntiAntiSwearingBot/Program.cs index e65c6d3..0725ca2 100644 --- a/AntiAntiSwearingBot/Program.cs +++ b/AntiAntiSwearingBot/Program.cs @@ -1,31 +1,34 @@ -using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using AntiAntiSwearingBot; -static void Log(string m) => Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff}|{m}"); +var builder = WebApplication.CreateBuilder(); -Log("AntiAntiSwearBot starting...."); - -var cfg = Config.Load("aasb.cfg.json", "aasb.cfg.secret.json"); -var dict = new SearchDictionary(cfg); -Log($"{dict.Count} words loaded."); -var bot = new AntiAntiSwearingBot.AntiAntiSwearingBot(cfg, dict); -bot.Init().Wait(); -Log($"Connected to Telegram as @{bot.Me.Username}"); -Log("AntiAntiSwearBot started! Press Ctrl-C to exit."); - -ManualResetEvent quitEvent = new ManualResetEvent(false); -try +builder.WebHost.ConfigureAppConfiguration((hostingContext, config) => { - Console.CancelKeyPress += (sender, eArgs) => // ctrl-c - { - eArgs.Cancel = true; - quitEvent.Set(); - }; -} -catch { } + var env = hostingContext.HostingEnvironment.EnvironmentName; + config.AddJsonFile("secrets.json", optional: true, reloadOnChange: true); + config.AddJsonFile($"secrets.{env}.json", optional: true, reloadOnChange: true); +}); -quitEvent.WaitOne(Timeout.Infinite); +var cfg = builder.Configuration; +var svc = builder.Services; -Console.WriteLine("Waiting for exit..."); -dict.Save(); +svc.Configure(cfg.GetSection("SearchDictionary")); +svc.Configure(cfg.GetSection("Telegram")); +svc.Configure(cfg.GetSection("Unbleeper")); +svc.AddHealthChecks().AddCheck("Startup"); +svc.AddHostedSingleton(); +svc.AddSingleton(); +svc.AddHostedSingleton(); + +var app = builder.Build(); +app.UseDeveloperExceptionPage(); +app.UseRouting(); +app.UseEndpoints(cfg => +{ + cfg.MapHealthChecks("/health"); +}); +app.Run(); diff --git a/AntiAntiSwearingBot/Properties/PublishProfiles/FolderProfile.pubxml b/AntiAntiSwearingBot/Properties/PublishProfiles/FolderProfile.pubxml index 302034d..ec705a2 100644 --- a/AntiAntiSwearingBot/Properties/PublishProfiles/FolderProfile.pubxml +++ b/AntiAntiSwearingBot/Properties/PublishProfiles/FolderProfile.pubxml @@ -7,9 +7,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121. FileSystem Release Any CPU - netcoreapp2.1 - i:\aasb - win-x64 + net6.0 + C:\Prog\releases\aasb false <_IsPortable>true diff --git a/AntiAntiSwearingBot/Properties/launchSettings.json b/AntiAntiSwearingBot/Properties/launchSettings.json new file mode 100644 index 0000000..cca1cfb --- /dev/null +++ b/AntiAntiSwearingBot/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:50671/", + "sslPort": 44398 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "AntiAntiSwearingBot": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} \ No newline at end of file diff --git a/AntiAntiSwearingBot/SearchDictionary.cs b/AntiAntiSwearingBot/SearchDictionary.cs index 08fb16f..02d778c 100644 --- a/AntiAntiSwearingBot/SearchDictionary.cs +++ b/AntiAntiSwearingBot/SearchDictionary.cs @@ -1,81 +1,91 @@ using System.IO; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Hosting; namespace AntiAntiSwearingBot; -public class SearchDictionary +public class SearchDictionary : BackgroundService { - public SearchDictionary(Config cfg) + public SearchDictionary(IOptionsMonitor cfg) { - var s = cfg.SearchDictionary; - path = s.DictionaryPath; - tmppath = path + ".tmp"; - + Cfg = cfg; + var path = cfg.CurrentValue.DictionaryPath; words = File.ReadAllLines(path).ToList(); } + IOptionsMonitor Cfg { get; } + ReaderWriterLockSlim DictLock = new(); + List words; + bool Changed = false; + public int Count => words.Count; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + if (Changed) Save(); + } + } + finally + { + Save(); + } + } + public void Save() { - if (File.Exists(tmppath)) - File.Delete(tmppath); + using var guard = DictLock.GetWriteLockToken(); + Changed = false; + + var path = Cfg.CurrentValue.DictionaryPath; + var tmppath = path + ".tmp"; + File.WriteAllLines(tmppath, words); - if (File.Exists(path)) - File.Delete(path); - File.Move(tmppath, path); + File.Move(tmppath, path, overwrite: true); } - public struct WordMatch - { - public string Word; - public int Distance; - public int Rating; - } + public record struct WordMatch (string Word, int Distance, int Rating); public WordMatch Match(string pattern) => AllMatches(pattern).First(); public IEnumerable AllMatches(string pattern) { - lock (SyncRoot) - { - pattern = pattern.ToLowerInvariant(); - return words - .Select((w, i) => new WordMatch { Word = w, Distance = Language.LevenshteinDistance(pattern, w), Rating = i }) - .OrderBy(m => m.Distance) - .ThenBy(m => m.Rating); - } + using var guard = DictLock.GetReadLockToken(); + return words + .Select((w, i) => new WordMatch(w, Language.LevenshteinDistance(pattern.ToLowerInvariant(), w), i)) + .OrderBy(m => m.Distance) + .ThenBy(m => m.Rating); } public bool Learn(string word) { - lock (SyncRoot) + using var guard = DictLock.GetWriteLockToken(); + Changed = true; + + int index = words.IndexOf(word); + if (index > 0) { - int index = words.IndexOf(word); - if (index > 0) - { - words.Move(index, 0); - return false; - } - else - { - words.Insert(0, word); - return true; - } + words.Move(index, 0); + return false; + } + else + { + words.Insert(0, word); + return true; } } public bool Unlearn(string word) { - lock (SyncRoot) - return words.Remove(word); + using var guard = DictLock.GetWriteLockToken(); + Changed = true; + return words.Remove(word); } - #region service - - readonly string path, tmppath; - - object SyncRoot = new object(); - List words; - - #endregion } diff --git a/AntiAntiSwearingBot/StartupHealthCheck.cs b/AntiAntiSwearingBot/StartupHealthCheck.cs new file mode 100644 index 0000000..8936eba --- /dev/null +++ b/AntiAntiSwearingBot/StartupHealthCheck.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace AntiAntiSwearingBot; +public class StartupHealthCheck : IHealthCheck +{ + Aasb Bot { get; } + + public StartupHealthCheck(Aasb bot) + { + Bot = bot; + } + + public Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (Bot.Started) + return Task.FromResult(HealthCheckResult.Healthy("The startup task has completed.")); + else + return Task.FromResult(HealthCheckResult.Unhealthy("That startup task is still running.")); + } +} \ No newline at end of file diff --git a/AntiAntiSwearingBot/Unbleeper.cs b/AntiAntiSwearingBot/Unbleeper.cs index 2b14dae..49d95e6 100644 --- a/AntiAntiSwearingBot/Unbleeper.cs +++ b/AntiAntiSwearingBot/Unbleeper.cs @@ -7,10 +7,10 @@ public class Unbleeper SearchDictionary Dict { get; } UnbleeperSettings Cfg { get; } - public Unbleeper(SearchDictionary dict, UnbleeperSettings cfg) + public Unbleeper(SearchDictionary dict, IOptions cfg) { Dict = dict; - Cfg = cfg; + Cfg = cfg.Value; BleepedSwearsRegex = new Regex("^" + Cfg.BleepedSwearsRegex + "$", RegexOptions.Compiled); } @@ -41,17 +41,15 @@ public class Unbleeper ) .ToArray(); - if (candidates.Any()) - { - var response = new StringBuilder(); - for (int i = 0; i < candidates.Length; ++i) - { - var m = Dict.Match(candidates[i]); - response.AppendLine(new string('*', i + 1) + m.Word + new string('?', m.Distance)); - } - return response.ToString(); - } - else + if (!candidates.Any()) return null; + + var response = new StringBuilder(); + for (int i = 0; i < candidates.Length; ++i) + { + var m = Dict.Match(candidates[i]); + response.AppendLine(new string('*', i + 1) + m.Word + new string('?', m.Distance)); + } + return response.ToString(); } } diff --git a/AntiAntiSwearingBot/aasb.cfg.json b/AntiAntiSwearingBot/appsettings.json similarity index 58% rename from AntiAntiSwearingBot/aasb.cfg.json rename to AntiAntiSwearingBot/appsettings.json index 3acfa59..b4a95dc 100644 --- a/AntiAntiSwearingBot/aasb.cfg.json +++ b/AntiAntiSwearingBot/appsettings.json @@ -1,4 +1,11 @@ { + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:9999" + } + } + }, "Unbleeper": { "BleepedSwearsRegex": "[а-яА-ЯёЁ@\\*#]+", "MinWordLength": 3, @@ -6,5 +13,8 @@ }, "SearchDictionary": { "DictionaryPath": "dict/ObsceneDictionaryRu.txt" + }, + "Telegram": { + "UseProxy": false } }