Massive rewrite

This commit is contained in:
jetsparrow 2023-03-26 22:40:44 +03:00
parent 16c3544526
commit bc4a16ff71
30 changed files with 1867 additions and 386 deletions

View File

@ -0,0 +1,35 @@
using Jetsparrow.Aasb.Services;
using Jetsparrow.Aasb.Tests.Utils;
namespace Jetsparrow.Aasb.Tests;
public class BleepTestsBase
{
public BleepTestsBase()
{
dictCfg = Options.Create(new SearchDictionarySettings
{
AutosavePeriod = TimeSpan.MaxValue,
DictionaryPath = "dict/ObsceneDictionaryRu.txt"
});
ublCfg = Options.Create(new UnbleeperSettings
{
LegalWordsRegex = "[а-яА-ЯёЁ]+",
BleepedSwearsRegex = "[а-яА-ЯёЁ@\\*#]+",
MinWordLength = 3,
MinAmbiguousWordLength = 5
});
lifetime = new FakeLifetime();
dict = new SearchDictionary(dictCfg, ublCfg, lifetime);
ubl = new Unbleeper(dict, ublCfg);
}
protected Unbleeper ubl { get; }
protected SearchDictionary dict { get; }
protected IOptions<SearchDictionarySettings> dictCfg { get; }
protected IOptions<UnbleeperSettings> ublCfg { get; }
protected IHostApplicationLifetime lifetime { get; }
}

View File

@ -1,29 +1,13 @@
using System; namespace Jetsparrow.Aasb.Tests;
using Microsoft.Extensions.Options; public class DetectTests : BleepTestsBase
using Xunit;
namespace Jetsparrow.Aasb.Tests;
public class DetectTests
{ {
Unbleeper ubl { get; }
SearchDictionary dict { get; }
public DetectTests()
{
dict = new SearchDictionary(MockOptionsMonitor.Create(DefaultSettings.SearchDictionary));
ubl = new Unbleeper(dict, Options.Create(DefaultSettings.Unbleeper));
}
[Theory] [Theory]
[InlineData("бл**ь", "*блядь")] [InlineData("бл**ь", "*блядь")]
[InlineData("ж**а", "*жопа")] [InlineData("ж**а", "*жопа")]
public void UnbleepSimpleSwears(string word, string expected) public async Task UnbleepSimpleSwears(string word, string expected)
{ {
var unbleep = ubl.UnbleepSwears(word).TrimEnd(Environment.NewLine.ToCharArray()); var unbleep = (await ubl.UnbleepSwears(word)).TrimEnd(Environment.NewLine.ToCharArray());
Assert.Equal(expected, unbleep); Assert.Equal(expected, unbleep);
} }
@ -34,9 +18,9 @@ public class DetectTests
[InlineData("еб*ть—колотить", "*ебать")] [InlineData("еб*ть—колотить", "*ебать")]
[InlineData("Получилась полная х**ня: даже не знаю, что и сказать, б**.", "*херня\n**бля")] [InlineData("Получилась полная х**ня: даже не знаю, что и сказать, б**.", "*херня\n**бля")]
[InlineData("Сергей опять вы**нулся своим знанием тонкостей русского языка; в окно еб*шил стылый ноябрьский ветер. ", "*выебнулся\n**ебашил")] [InlineData("Сергей опять вы**нулся своим знанием тонкостей русского языка; в окно еб*шил стылый ноябрьский ветер. ", "*выебнулся\n**ебашил")]
public void DetectWordsWithPunctuation(string text, string expected) public async void DetectWordsWithPunctuation(string text, string expected)
{ {
var unbleep = ubl.UnbleepSwears(text).Replace("\r\n", "\n").Trim(); var unbleep = (await ubl.UnbleepSwears(text)).Replace("\r\n", "\n").Trim();
Assert.Equal(expected, unbleep); Assert.Equal(expected, unbleep);
} }
} }

View File

@ -1,20 +1,6 @@
using Microsoft.Extensions.Options; namespace Jetsparrow.Aasb.Tests;
public class FilterTests : BleepTestsBase
using Xunit;
namespace Jetsparrow.Aasb.Tests;
public class FilterTests
{ {
Unbleeper ubl { get; }
SearchDictionary dict { get; }
public FilterTests()
{
dict = new SearchDictionary(MockOptionsMonitor.Create(DefaultSettings.SearchDictionary));
ubl = new Unbleeper(dict, Options.Create(DefaultSettings.Unbleeper));
}
[Theory] [Theory]
[InlineData("*")] [InlineData("*")]
[InlineData("**#")] [InlineData("**#")]

View File

@ -0,0 +1,11 @@
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Text;
global using System.Threading.Tasks;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Options;
global using Xunit;

View File

@ -16,4 +16,10 @@
<ProjectReference Include="..\Jetsparrow.Aasb\Jetsparrow.Aasb.csproj" /> <ProjectReference Include="..\Jetsparrow.Aasb\Jetsparrow.Aasb.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="dict\ObsceneDictionaryRu.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,18 @@
using System.Threading;
namespace Jetsparrow.Aasb.Tests.Utils;
public class FakeLifetime : IHostApplicationLifetime
{
CancellationTokenSource Started = new(), Stopping = new(), Stopped = new();
public CancellationToken ApplicationStarted => Started.Token;
public CancellationToken ApplicationStopping => Stopping.Token;
public CancellationToken ApplicationStopped => Stopped.Token;
public void StopApplication()
{
Stopping.Cancel();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
{
"version": 1,
"isRoot": true,
"tools": {}
}

View File

@ -1,102 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using Telegram.Bot;
using Telegram.Bot.Extensions.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Jetsparrow.Aasb.Commands;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jetsparrow.Aasb;
public class Aasb : IHostedService
{
SearchDictionary Dict { get; }
Unbleeper Unbleeper { get; }
ILogger Log { get; }
public bool Started { get; private set; } = false;
public Aasb(ILogger<Aasb> log, IOptions<TelegramSettings> tgCfg, Unbleeper unbp)
{
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 StartAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(TelegramSettings.ApiKey))
return;
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");
var receiverOptions = new ReceiverOptions { AllowedUpdates = new[] { UpdateType.Message } };
TelegramBot.StartReceiving(
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)
{
Log.LogError(exception, "Exception while handling API message");
return Task.CompletedTask;
}
async Task HandleUpdateAsync(ITelegramBotClient sender, Update update, CancellationToken cancellationToken)
{
if (update.Type != UpdateType.Message || update?.Message?.Type != MessageType.Text)
return;
var msg = update.Message!;
try
{
string commandResponse = null;
try { commandResponse = Router.Execute(sender, update); }
catch { }
if (commandResponse != null)
{
await TelegramBot.SendTextMessageAsync(
msg.Chat.Id,
commandResponse,
replyToMessageId: msg.MessageId,
disableNotification: true);
}
else
{
var unbleepResponse = Unbleeper.UnbleepSwears(msg.Text);
if (unbleepResponse != null)
await TelegramBot.SendTextMessageAsync(
msg.Chat.Id,
unbleepResponse,
replyToMessageId: msg.MessageId,
disableNotification: true);
}
}
catch (Exception e)
{
Log.LogError(e, "Exception while handling message {0}", msg);
}
}
}

View File

@ -1,44 +0,0 @@
using Telegram.Bot.Types;
using Jetsparrow.Aasb.Commands;
namespace Jetsparrow.Aasb;
public interface IChatCommand
{
string Execute(CommandString cmd, Update messageEventArgs);
}
public class ChatCommandRouter
{
string Username { get; }
Dictionary<string, IChatCommand> Commands { get; }
public ChatCommandRouter(string username)
{
Username = username;
Commands = new Dictionary<string, IChatCommand>();
}
public string Execute(object sender, Update args)
{
var text = args.Message.Text;
if (CommandString.TryParse(text, out var cmd))
{
if (cmd.Username != null && cmd.Username != Username)
return null;
if (Commands.ContainsKey(cmd.Command))
return Commands[cmd.Command].Execute(cmd, args);
}
return null;
}
public void Add(IChatCommand c, params string[] cmds)
{
foreach (var cmd in cmds)
{
if (Commands.ContainsKey(cmd))
throw new ArgumentException($"collision for {cmd}, commands {Commands[cmd].GetType()} and {c.GetType()}");
Commands[cmd] = c;
}
}
}

View File

@ -0,0 +1,43 @@
namespace Jetsparrow.Aasb.Commands;
public class ChatCommandRouter
{
string BotUsername { get; }
Dictionary<string, IChatCommand> Commands { get; }
IOptionsMonitor<AccessSettings> Access { get; }
public ChatCommandRouter(string username, IOptionsMonitor<AccessSettings> accessCfg)
{
BotUsername = username;
Access = accessCfg;
Commands = new Dictionary<string, IChatCommand>();
}
public void Register(IChatCommand c, params string[] cmds)
{
foreach (var cmd in cmds)
{
if (Commands.ContainsKey(cmd))
throw new ArgumentException($"collision for {cmd}, commands {Commands[cmd].GetType()} and {c.GetType()}");
Commands[cmd] = c;
}
}
public string Execute(CommandContext cmd)
{
var allowed = Access.CurrentValue.AllowedChats;
if (cmd.Recipient != null && cmd.Recipient != BotUsername)
return null;
if (!Commands.TryGetValue(cmd.Command, out var handler))
return null;
if (handler.Authorize)
{
if (!allowed.Contains(cmd.ChatId) && !allowed.Contains(cmd.SenderId))
return null;
}
return handler.Execute(cmd);
}
}

View File

@ -1,40 +1,9 @@
using System.Text.RegularExpressions; namespace Jetsparrow.Aasb.Commands;
public class CommandContext
namespace Jetsparrow.Aasb.Commands;
public class CommandString
{ {
public CommandString(string command, string username, params string[] parameters) public string Command { get; set; }
{ public string Recipient { get; set; }
Command = command; public string ChatId { get; set; }
Username = username; public string SenderId { get; set; }
Parameters = parameters; public string[] Parameters { get; set; }
}
public string Command { get; }
public string Username { get; }
public string[] Parameters { get; }
static readonly char[] WS_CHARS = new[] { ' ', '\r', '\n', '\n' };
public static bool TryParse(string s, out CommandString result)
{
result = null;
if (string.IsNullOrWhiteSpace(s) || s[0] != '/')
return false;
string[] words = s.Split(WS_CHARS, StringSplitOptions.RemoveEmptyEntries);
var cmdRegex = new Regex(@"/(?<cmd>\w+)(@(?<name>\w+))?");
var match = cmdRegex.Match(words.First());
if (!match.Success)
return false;
string cmd = match.Groups["cmd"].Captures[0].Value;
string username = match.Groups["name"].Captures.Count > 0 ? match.Groups["name"].Captures[0].Value : null;
string[] parameters = words.Skip(1).ToArray();
result = new CommandString(cmd, username, parameters);
return true;
}
} }

View File

@ -0,0 +1,6 @@
namespace Jetsparrow.Aasb.Commands;
public interface IChatCommand
{
bool Authorize { get; }
string Execute(CommandContext cmd);
}

View File

@ -0,0 +1,11 @@
namespace Jetsparrow.Aasb.Commands;
public class IdCommand : IChatCommand
{
public bool Authorize => true;
public string Execute(CommandContext cmd)
{
if (cmd.ChatId == cmd.SenderId)
return $"userid: `{cmd.SenderId}`";
return $"chatid: `{cmd.ChatId}`";
}
}

View File

@ -1,26 +1,28 @@
using System.Text.RegularExpressions; using Jetsparrow.Aasb.Services;
using Telegram.Bot.Types;
namespace Jetsparrow.Aasb.Commands; namespace Jetsparrow.Aasb.Commands;
public class LearnCommand : IChatCommand public class LearnCommand : IChatCommand
{ {
public bool Authorize => true;
SearchDictionary Dict { get; } SearchDictionary Dict { get; }
public LearnCommand(SearchDictionary dict) public LearnCommand(SearchDictionary dict)
{ {
Dict = dict; Dict = dict;
} }
public string Execute(CommandString cmd, Update args) public string Execute(CommandContext cmd)
{ {
var word = cmd.Parameters.FirstOrDefault(); var word = cmd.Parameters.FirstOrDefault();
if (string.IsNullOrWhiteSpace(word)) if (string.IsNullOrWhiteSpace(word))
return null; return null;
if (!Regex.IsMatch(word, @"[а-яА-Я]+")) var learnRes = Dict.Learn(word);
return null; return learnRes switch
{
bool newWord = Dict.Learn(word); SearchDictionary.LearnResult.Known => $"Я знаю что такое \"{word}\"",
return newWord ? $"Принято слово \"{word}\"" : $"Поднял рейтинг слову \"{word}\""; SearchDictionary.LearnResult.Added => $"Понял принял, \"{word}\"",
SearchDictionary.LearnResult.Illegal => "Я такое запоминать не буду",
_ => "ась?"
};
} }
} }

View File

@ -1,9 +1,9 @@
using System.Text.RegularExpressions; using Jetsparrow.Aasb.Services;
using Telegram.Bot.Types;
namespace Jetsparrow.Aasb.Commands; namespace Jetsparrow.Aasb.Commands;
public class UnlearnCommand : IChatCommand public class UnlearnCommand : IChatCommand
{ {
public bool Authorize => true;
SearchDictionary Dict { get; } SearchDictionary Dict { get; }
public UnlearnCommand(SearchDictionary dict) public UnlearnCommand(SearchDictionary dict)
@ -11,17 +11,15 @@ public class UnlearnCommand : IChatCommand
Dict = dict; Dict = dict;
} }
public string Execute(CommandString cmd, Update args) public string Execute(CommandContext cmd)
{ {
var word = cmd.Parameters.FirstOrDefault(); var word = cmd.Parameters.FirstOrDefault();
if (string.IsNullOrWhiteSpace(word)) if (string.IsNullOrWhiteSpace(word))
return null; return null;
if (!Regex.IsMatch(word, @"[а-яА-Я]+"))
return null;
if (Dict.Unlearn(word)) if (Dict.Unlearn(word))
return $"Удалил слово \"{word}\""; return $"Больше не буду";
else else
return $"Не нашел слово \"{word}\""; return $"А я и не знаю что такое \"{word}\"";
} }
} }

View File

@ -1,12 +1,10 @@
namespace Jetsparrow.Aasb; using System.ComponentModel.DataAnnotations;
public class ServiceSettings namespace Jetsparrow.Aasb;
{
public string Urls { get; set; }
}
public class UnbleeperSettings public class UnbleeperSettings
{ {
public string LegalWordsRegex { get; set; }
public string BleepedSwearsRegex { get; set; } public string BleepedSwearsRegex { get; set; }
public int MinAmbiguousWordLength { get; set; } public int MinAmbiguousWordLength { get; set; }
public int MinWordLength { get; set; } public int MinWordLength { get; set; }
@ -15,14 +13,17 @@ public class UnbleeperSettings
public class SearchDictionarySettings public class SearchDictionarySettings
{ {
public string DictionaryPath { get; set; } public string DictionaryPath { get; set; }
[Range(typeof(TimeSpan), "00:00:10", "01:00:00")]
public TimeSpan AutosavePeriod { get; set; }
} }
public class TelegramSettings public class TelegramSettings
{ {
public string ApiKey { get; set; } public string ApiKey { get; set; }
public bool UseProxy { get; set; } }
public string Url { get; set; }
public int Port { get; set; } public class AccessSettings
public string Login { get; set; } {
public string Password { get; set; } public string[] AllowedChats { get; set; }
} }

View File

@ -2,5 +2,9 @@
global using System.Text; global using System.Text;
global using System.Linq; global using System.Linq;
global using System.Collections.Generic; global using System.Collections.Generic;
global using System.Threading;
global using System.Threading.Tasks;
global using Microsoft.Extensions.Options; global using Microsoft.Extensions.Options;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Hosting;

View File

@ -0,0 +1,52 @@
using System.IO;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Jetsparrow.Aasb.Health;
public static class HealthUtils
{
public static Task WriteHealthCheckResponse(HttpContext context, HealthReport healthReport)
{
context.Response.ContentType = "application/json; charset=utf-8";
var options = new JsonWriterOptions { Indented = true };
using var memoryStream = new MemoryStream();
using (var jsonWriter = new Utf8JsonWriter(memoryStream, options))
{
jsonWriter.WriteStartObject();
jsonWriter.WriteString("status", healthReport.Status.ToString());
jsonWriter.WriteStartObject("results");
foreach (var healthReportEntry in healthReport.Entries)
{
jsonWriter.WriteStartObject(healthReportEntry.Key);
jsonWriter.WriteString("status",
healthReportEntry.Value.Status.ToString());
jsonWriter.WriteString("description",
healthReportEntry.Value.Description);
jsonWriter.WriteStartObject("data");
foreach (var item in healthReportEntry.Value.Data)
{
jsonWriter.WritePropertyName(item.Key);
JsonSerializer.Serialize(jsonWriter, item.Value,
item.Value?.GetType() ?? typeof(object));
}
jsonWriter.WriteEndObject();
jsonWriter.WriteEndObject();
}
jsonWriter.WriteEndObject();
jsonWriter.WriteEndObject();
}
return context.Response.WriteAsync(
Encoding.UTF8.GetString(memoryStream.ToArray()));
}
}

View File

@ -1,14 +1,13 @@
using System.Threading; using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Jetsparrow.Aasb.Services;
namespace Jetsparrow.Aasb; namespace Jetsparrow.Aasb.Health;
public class StartupHealthCheck : IHealthCheck public class StartupHealthCheck : IHealthCheck
{ {
Aasb Bot { get; } AntiAntiSwearingBot Bot { get; }
public StartupHealthCheck(Aasb bot) public StartupHealthCheck(AntiAntiSwearingBot bot)
{ {
Bot = bot; Bot = bot;
} }

View File

@ -3,6 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<UseAppHost>False</UseAppHost>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -10,7 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Telegram.Bot.Extensions.Polling" Version="2.0.0-alpha.1" /> <PackageReference Include="Telegram.Bot" Version="18.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -34,4 +35,8 @@
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Properties\PublishProfiles\" />
</ItemGroup>
</Project> </Project>

View File

@ -1,7 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace Jetsparrow.Aasb; namespace Jetsparrow.Aasb;
public static class Language public static class StringEx
{ {
static int min(int a, int b, int c) { return Math.Min(Math.Min(a, b), c); } static int min(int a, int b, int c) { return Math.Min(Math.Min(a, b), c); }

View File

@ -1,34 +1,37 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Jetsparrow.Aasb; using Jetsparrow.Aasb;
using Jetsparrow.Aasb.Services;
using Jetsparrow.Aasb.Health;
var builder = WebApplication.CreateBuilder(); var builder = WebApplication.CreateBuilder();
Console.WriteLine("Configuring...");
builder.WebHost.ConfigureAppConfiguration((hostingContext, config) =>
{
var env = hostingContext.HostingEnvironment.EnvironmentName;
config.AddJsonFile("secrets.json", optional: true, reloadOnChange: true);
config.AddJsonFile($"secrets.{env}.json", optional: true, reloadOnChange: true);
});
var cfg = builder.Configuration; var cfg = builder.Configuration;
var svc = builder.Services; var svc = builder.Services;
svc.Configure<SearchDictionarySettings>(cfg.GetSection("SearchDictionary")); svc.AddOptions<SearchDictionarySettings>().BindConfiguration("SearchDictionary").ValidateDataAnnotations();
svc.Configure<TelegramSettings>(cfg.GetSection("Telegram")); svc.AddOptions<TelegramSettings>().BindConfiguration("Telegram");
svc.Configure<UnbleeperSettings>(cfg.GetSection("Unbleeper")); svc.AddOptions<UnbleeperSettings>().BindConfiguration("Unbleeper");
svc.AddHealthChecks().AddCheck<StartupHealthCheck>("Startup"); svc.AddHealthChecks().AddCheck<StartupHealthCheck>("Startup");
svc.AddHostedSingleton<SearchDictionary>(); svc.AddSingleton<SearchDictionary>();
svc.AddSingleton<Unbleeper>(); svc.AddSingleton<Unbleeper>();
svc.AddHostedSingleton<Aasb>(); svc.AddHostedSingleton<AntiAntiSwearingBot>();
Console.WriteLine("Building...");
var app = builder.Build(); var app = builder.Build();
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
app.UseRouting(); app.UseRouting();
app.UseEndpoints(cfg => app.UseEndpoints(cfg =>
{ {
cfg.MapHealthChecks("/health"); cfg.MapHealthChecks("/health");
cfg.MapHealthChecks("/health/verbose", new()
{
ResponseWriter = HealthUtils.WriteHealthCheckResponse
}); });
});
Console.WriteLine("Running...");
app.Run(); app.Run();

View File

@ -2,14 +2,15 @@
<!-- <!--
https://go.microsoft.com/fwlink/?LinkID=208121. https://go.microsoft.com/fwlink/?LinkID=208121.
--> -->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project>
<PropertyGroup> <PropertyGroup>
<PublishProtocol>FileSystem</PublishProtocol> <DeleteExistingFiles>True</DeleteExistingFiles>
<Configuration>Release</Configuration> <ExcludeApp_Data>False</ExcludeApp_Data>
<Platform>Any CPU</Platform> <LaunchSiteAfterPublish>True</LaunchSiteAfterPublish>
<TargetFramework>net6.0</TargetFramework> <LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<PublishDir>C:\Prog\releases\aasb</PublishDir> <LastUsedPlatform>Any CPU</LastUsedPlatform>
<SelfContained>false</SelfContained> <PublishProvider>FileSystem</PublishProvider>
<_IsPortable>true</_IsPortable> <PublishUrl>C:\Prog\releases\aasb</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -1,91 +0,0 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
namespace Jetsparrow.Aasb;
public class SearchDictionary : BackgroundService
{
public SearchDictionary(IOptionsMonitor<SearchDictionarySettings> cfg)
{
Cfg = cfg;
var path = cfg.CurrentValue.DictionaryPath;
words = File.ReadAllLines(path).ToList();
}
IOptionsMonitor<SearchDictionarySettings> Cfg { get; }
ReaderWriterLockSlim DictLock = new();
List<string> 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()
{
using var guard = DictLock.GetWriteLockToken();
Changed = false;
var path = Cfg.CurrentValue.DictionaryPath;
var tmppath = path + ".tmp";
File.WriteAllLines(tmppath, words);
File.Move(tmppath, path, overwrite: true);
}
public record struct WordMatch (string Word, int Distance, int Rating);
public WordMatch Match(string pattern)
=> AllMatches(pattern).First();
public IEnumerable<WordMatch> AllMatches(string pattern)
{
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)
{
using var guard = DictLock.GetWriteLockToken();
Changed = true;
int index = words.IndexOf(word);
if (index > 0)
{
words.Move(index, 0);
return false;
}
else
{
words.Insert(0, word);
return true;
}
}
public bool Unlearn(string word)
{
using var guard = DictLock.GetWriteLockToken();
Changed = true;
return words.Remove(word);
}
}

View File

@ -0,0 +1,146 @@
using System.Text.RegularExpressions;
using Telegram.Bot;
using Telegram.Bot.Polling;
using Telegram.Bot.Types;
using Telegram.Bot.Types.Enums;
using Jetsparrow.Aasb.Commands;
namespace Jetsparrow.Aasb.Services;
public class AntiAntiSwearingBot : IHostedService
{
SearchDictionary Dict { get; }
Unbleeper Unbleeper { get; }
IOptionsMonitor<AccessSettings> AccessCfg { get; }
ILogger Log { get; }
public bool Started { get; private set; } = false;
public AntiAntiSwearingBot(
ILogger<AntiAntiSwearingBot> log,
IOptions<TelegramSettings> tgCfg,
Unbleeper unbp,
IOptionsMonitor<AccessSettings> accessCfg)
{
Log = log;
TelegramSettings = tgCfg.Value;
Unbleeper = unbp;
AccessCfg = accessCfg;
}
TelegramSettings TelegramSettings { get; }
TelegramBotClient TelegramBot { get; set; }
ChatCommandRouter Router { get; set; }
public User Me { get; private set; }
public async Task StartAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(TelegramSettings.ApiKey))
{
Log.LogWarning("No ApiKey found in config!");
throw new Exception("No ApiKey found in config!");
return;
}
TelegramBot = new TelegramBotClient(TelegramSettings.ApiKey);
Log.LogInformation("Connecting to Telegram...");
Me = await TelegramBot.GetMeAsync();
Log.LogInformation("Connected to Telegram as @{Username}", Me.Username);
Router = new ChatCommandRouter(Me.Username, AccessCfg);
Router.Register(new LearnCommand(Dict), "learn");
Router.Register(new UnlearnCommand(Dict), "unlearn");
Router.Register(new IdCommand(), "chatid");
var receiverOptions = new ReceiverOptions { AllowedUpdates = new[] { UpdateType.Message } };
TelegramBot.StartReceiving(
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)
{
Log.LogError(exception, "Exception while handling API message");
return Task.CompletedTask;
}
async Task HandleUpdateAsync(ITelegramBotClient sender, Update update, CancellationToken cancellationToken)
{
if (update.Type != UpdateType.Message || update?.Message?.Type != MessageType.Text)
return;
var msg = update.Message!;
try
{
if (TryParseCommand(update, out var cmd))
{
var cmdResponse = Router.Execute(cmd);
if (cmdResponse != null)
{
await TelegramBot.SendTextMessageAsync(
msg.Chat.Id,
cmdResponse,
replyToMessageId: msg.MessageId,
parseMode: ParseMode.MarkdownV2,
disableNotification: true);
}
}
else
{
var unbleepResponse = await Unbleeper.UnbleepSwears(msg.Text);
if (unbleepResponse != null)
await TelegramBot.SendTextMessageAsync(
msg.Chat.Id,
unbleepResponse,
replyToMessageId: msg.MessageId,
disableNotification: true);
}
}
catch (Exception e)
{
Log.LogError(e, "Exception while handling message {0}", msg);
}
}
const string NS_TELEGRAM = "telegram://";
static readonly char[] WS_CHARS = new[] { ' ', '\r', '\n', '\n' };
bool TryParseCommand(Update update, out CommandContext result)
{
var s = update.Message.Text;
result = null;
if (string.IsNullOrWhiteSpace(s) || s[0] != '/')
return false;
string[] words = s.Split(WS_CHARS, StringSplitOptions.RemoveEmptyEntries);
var cmdRegex = new Regex(@"/(?<cmd>\w+)(@(?<name>\w+))?");
var match = cmdRegex.Match(words.First());
if (!match.Success)
return false;
string cmd = match.Groups["cmd"].Captures[0].Value;
string username = match.Groups["name"].Captures.Count > 0 ? match.Groups["name"].Captures[0].Value : null;
string[] parameters = words.Skip(1).ToArray();
result = new CommandContext
{
Command = cmd,
ChatId = NS_TELEGRAM + update.Message.Chat.Id,
SenderId = NS_TELEGRAM + update.Message.From.Id,
Recipient = username,
Parameters = parameters
};
return true;
}
}

View File

@ -0,0 +1,109 @@
using System.IO;
using System.Text.RegularExpressions;
namespace Jetsparrow.Aasb.Services;
public record struct WordMatch(string Word, int Distance);
public class SearchDictionary
{
SearchDictionarySettings Cfg { get; }
UnbleeperSettings UnbleeperCfg { get; }
Regex LegalWordsRegex { get; }
public SearchDictionary(
IOptions<SearchDictionarySettings> searchDictionaryCfg,
IOptions<UnbleeperSettings> unbleeperCfg,
IHostApplicationLifetime lifetime)
{
Cfg = searchDictionaryCfg.Value;
UnbleeperCfg = unbleeperCfg.Value;
Loaded = Load();
Autosave = AutosaveLoop(lifetime.ApplicationStopping);
LegalWordsRegex = new Regex(UnbleeperCfg.LegalWordsRegex, RegexOptions.Compiled);
}
public async Task<WordMatch> Match(string pattern)
{
await Loaded;
var matches = TopMatches(pattern);
return matches[Random.Shared.Next(0, matches.Count)];
}
IList<WordMatch> TopMatches(string pattern)
{
pattern = pattern.ToLowerInvariant();
List<WordMatch> matches;
using (var guard = DictLock.GetReadLockToken())
{
matches = words
.Select((w, i) => new WordMatch(w, StringEx.LevenshteinDistance(pattern, w)))
.ToList();
};
var minDist = matches.Min(w => w.Distance);
return matches.Where(w => w.Distance == minDist).ToList();
}
public enum LearnResult { Illegal, Added, Known }
public LearnResult Learn(string word)
{
using var guard = DictLock.GetWriteLockToken();
if (words.Contains(word))
return LearnResult.Known;
if (!LegalWordsRegex.IsMatch(word))
return LearnResult.Illegal;
words.Add(word);
Changed = true;
return LearnResult.Added;
}
public bool Unlearn(string word)
{
using var guard = DictLock.GetWriteLockToken();
var res = words.Remove(word);
Changed |= res;
return res;
}
ReaderWriterLockSlim DictLock = new();
List<string> words;
#region load/save
Task Loaded, Autosave;
bool Changed;
async Task Load()
{
words = new List<string>(await File.ReadAllLinesAsync(Cfg.DictionaryPath));
}
async Task AutosaveLoop(CancellationToken stopToken)
{
try
{
while (!stopToken.IsCancellationRequested)
{
await Task.Delay(Cfg.AutosavePeriod, stopToken);
DoSave();
}
}
catch (TaskCanceledException) { }
DoSave();
}
void DoSave()
{
if (!Changed) return;
using (var guard = DictLock.GetWriteLockToken())
{
if (!Changed) return;
Changed = false;
var tmppath = Cfg.DictionaryPath + ".tmp";
File.WriteAllLines(tmppath, words);
File.Move(tmppath, Cfg.DictionaryPath, overwrite: true);
}
}
#endregion
}

View File

@ -1,7 +1,6 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace Jetsparrow.Aasb; namespace Jetsparrow.Aasb.Services;
public class Unbleeper public class Unbleeper
{ {
SearchDictionary Dict { get; } SearchDictionary Dict { get; }
@ -11,43 +10,45 @@ public class Unbleeper
{ {
Dict = dict; Dict = dict;
Cfg = cfg.Value; Cfg = cfg.Value;
BleepedSwearsRegex = new Regex("^" + Cfg.BleepedSwearsRegex + "$", RegexOptions.Compiled); var toBleep = Cfg.BleepedSwearsRegex;
if (!toBleep.StartsWith('^')) toBleep = "^" + toBleep;
if (!toBleep.EndsWith('$')) toBleep = toBleep + "$";
BleepedSwearsRegex = new Regex(toBleep, RegexOptions.Compiled);
} }
Regex BleepedSwearsRegex { get; } Regex BleepedSwearsRegex { get; }
static readonly char[] WORD_SEPARATORS = { ' ', '\t', '\r', '\n', '.', ',', '!', '?', ';', ':', '-', '—' }; static readonly char[] WORD_SEPARATORS = { ' ', '\t', '\r', '\n', '.', ',', '!', '?', ';', ':', '-', '—' };
public string UnbleepSwears(string text) public async Task<string> UnbleepSwears(string text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
return null; return null;
text = text.Trim(); text = text.Trim();
if (text.StartsWith('/')) // is chat command
return null;
var words = text.Split(WORD_SEPARATORS, StringSplitOptions.RemoveEmptyEntries); var words = text.Split(WORD_SEPARATORS, StringSplitOptions.RemoveEmptyEntries);
var candidates = words var candidates = words
.Where(w => .Where(w =>
!Language.IsTelegramMention(w) StringEx.HasNonWordChars(w)
&& !Language.IsEmailPart(w) && (StringEx.HasWordChars(w) || w.Length >= Cfg.MinAmbiguousWordLength)
&& Language.HasNonWordChars(w)
&& !Language.IsHashTag(w)
&& (Language.HasWordChars(w) || w.Length >= Cfg.MinAmbiguousWordLength)
&& w.Length >= Cfg.MinWordLength && w.Length >= Cfg.MinWordLength
&& !StringEx.IsTelegramMention(w)
&& !StringEx.IsEmailPart(w)
&& !StringEx.IsHashTag(w)
&& BleepedSwearsRegex.IsMatch(w) && BleepedSwearsRegex.IsMatch(w)
) )
.ToArray(); .ToList();
if (!candidates.Any()) if (!candidates.Any())
return null; return null;
var response = new StringBuilder(); var response = new StringBuilder();
for (int i = 0; i < candidates.Length; ++i) for (int i = 0; i < candidates.Count; ++i)
{ {
var m = Dict.Match(candidates[i]); var m = await Dict.Match(candidates[i]);
response.AppendLine(new string('*', i + 1) + m.Word + new string('?', m.Distance)); response.AppendLine(new string('*', i + 1) + m.Word + new string('?', m.Distance));
} }
return response.ToString(); return response.ToString();

View File

@ -1,4 +1,10 @@
{ {
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {
"Http": { "Http": {
@ -7,12 +13,14 @@
} }
}, },
"Unbleeper": { "Unbleeper": {
"LegalWordsRegex": "[а-яА-ЯёЁ]+",
"BleepedSwearsRegex": "[а-яА-ЯёЁ@\\*#]+", "BleepedSwearsRegex": "[а-яА-ЯёЁ@\\*#]+",
"MinWordLength": 3, "MinWordLength": 3,
"MinAmbiguousWordLength": 5 "MinAmbiguousWordLength": 5
}, },
"SearchDictionary": { "SearchDictionary": {
"DictionaryPath": "dict/ObsceneDictionaryRu.txt" "DictionaryPath": "dict/ObsceneDictionaryRu.txt",
"AutosavePeriod": "00:01:00"
}, },
"Telegram": { "Telegram": {
"UseProxy": false "UseProxy": false