diff --git a/modules/ChatManagement/Handler/ManagementHandler.cs b/Core/CommonHandler.cs similarity index 92% rename from modules/ChatManagement/Handler/ManagementHandler.cs rename to Core/CommonHandler.cs index 1fb3be6..dee081e 100644 --- a/modules/ChatManagement/Handler/ManagementHandler.cs +++ b/Core/CommonHandler.cs @@ -9,9 +9,9 @@ using Telegram.Bot; using Telegram.Bot.Types; using Telegram.Bot.Types.Enums; -namespace West.TelegramBot.ChatManagement.Handler +namespace Kruzya.TelegramBot.Core { - public abstract class ManagementHandler : BotEventHandler + public abstract class CommonHandler : BotEventHandler { protected readonly CoreContext Db; @@ -19,7 +19,7 @@ namespace West.TelegramBot.ChatManagement.Handler protected User? RepliedUser => Message?.ReplyToMessage?.From; - protected ManagementHandler(CoreContext db) + protected CommonHandler(CoreContext db) { Db = db; } diff --git a/Core/Data/IReputationEntity.cs b/Core/Data/IReputationEntity.cs new file mode 100644 index 0000000..282d3b3 --- /dev/null +++ b/Core/Data/IReputationEntity.cs @@ -0,0 +1,8 @@ +namespace Kruzya.TelegramBot.Core.Data; + +public interface IReputationEntity +{ + public long UserId { get; set; } + public long ChatId { get; set; } + public double Value { get; set; } +} \ No newline at end of file diff --git a/Core/Extensions/DbContextExtension.cs b/Core/Extensions/DbContextExtension.cs index 847d109..b425039 100644 --- a/Core/Extensions/DbContextExtension.cs +++ b/Core/Extensions/DbContextExtension.cs @@ -45,7 +45,7 @@ public static class DbContextExtension dbContext.Update(entity); dbContext.SaveChanges(); } - catch (DbUpdateConcurrencyException e) + catch (DbUpdateConcurrencyException) { dbContext.Add(entity); dbContext.SaveChanges(); diff --git a/Core/Service/IReputation.cs b/Core/Service/IReputation.cs new file mode 100644 index 0000000..ab1472d --- /dev/null +++ b/Core/Service/IReputation.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Kruzya.TelegramBot.Core.Data; +using Telegram.Bot.Types; + +namespace Kruzya.TelegramBot.Core.Service +{ + public interface IReputation + { + public Task GetUserReputationAsync(Chat chat, User user); + public Task SetUserReputationAsync(Chat chat, User user, double value); + public Task GetReputationDiffAsync(Chat chat, User sender, User receiver); + public Task IncrementReputationAsync(Chat chat, User user, double diff); + public Task> GetChatRatingAsync(Chat chat, int limit = 10, int offset = 0); + } +} diff --git a/Core/Service/UserService.cs b/Core/Service/UserService.cs index 251d02e..5fa261a 100644 --- a/Core/Service/UserService.cs +++ b/Core/Service/UserService.cs @@ -3,7 +3,12 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using BotFramework.Abstractions; +using Kruzya.TelegramBot.Core.Cache; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Telegram.Bot; using Telegram.Bot.Types; namespace Kruzya.TelegramBot.Core.Service @@ -11,10 +16,17 @@ namespace Kruzya.TelegramBot.Core.Service public class UserService { private readonly List _superUsers; - private readonly Dictionary _statusMap; + private readonly ICacheStorage _userCache; + private readonly ITelegramBotClient _bot; + private readonly ILogger _logger; - public UserService(IConfiguration configuration) + + public UserService( + IConfiguration configuration, + IBotInstance bot, + ICacheStorage userCache, + ILogger logger) { _superUsers = configuration.GetSection("SuperUsers") .GetChildren() @@ -24,6 +36,41 @@ namespace Kruzya.TelegramBot.Core.Service _statusMap = configuration.GetSection("CustomMemberStatus") .GetChildren() .ToDictionary(x => Convert.ToInt64(x.Key), x => x.Value); + + _userCache = userCache; + _bot = bot.BotClient; + _logger = logger; + } + + public async Task GetUser(long userId, long chatId, bool bypassCache = false) + { + if (bypassCache || !_userCache.TryGetValue(userId, out var user)) + { + if (bypassCache) + { + _logger.LogDebug("Bypassing cache for {UserId}.", userId); + } + else + { + _logger.LogDebug("User {UserId} is not found in cache. Fetching from Telegram API.", userId); + } + + user = await GetUserByChat(chatId, userId); + CacheUser(user); + } + + return user; + } + + private void CacheUser(User user) + { + // TODO: config entry for TTL + _userCache.SetValue(user.Id, user, Convert.ToInt32(TimeSpan.FromHours(1).TotalSeconds)); + } + + private async Task GetUserByChat(long chatId, long userId) + { + return (await _bot.GetChatMemberAsync(chatId, userId)).User; } public bool IsUserSuper(User user) diff --git a/Dockerfile b/Dockerfile index da3dfa6..167e1dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ COPY ./modules/ChatManagement/*.csproj ./modules/ChatManagement/ COPY ./modules/Entertainment/*.csproj ./modules/Entertainment/ COPY ./modules/UserGroupTag/*.csproj ./modules/UserGroupTag/ COPY ./modules/ChatQuotes/*.csproj ./modules/ChatQuotes/ +COPY ./modules/Reputation/*.csproj ./modules/Reputation/ RUN dotnet restore Kruzya.TelegramBot.sln # Copy all project files diff --git a/Kruzya.TelegramBot.sln b/Kruzya.TelegramBot.sln index 72016fc..0dcdad6 100644 --- a/Kruzya.TelegramBot.sln +++ b/Kruzya.TelegramBot.sln @@ -23,7 +23,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UserGroupTag", "modules\Use EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Entertainment", "modules\Entertainment\Entertainment.csproj", "{1D62101B-C2B1-4E79-8F7A-690E44E3EE81}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChatQuotes", "modules\ChatQuotes\ChatQuotes.csproj", "{04F16325-8F44-4250-9B2E-0F77E8F65CBF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ChatQuotes", "modules\ChatQuotes\ChatQuotes.csproj", "{04F16325-8F44-4250-9B2E-0F77E8F65CBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Reputation", "modules\Reputation\Reputation.csproj", "{03F3C7D5-6FEF-4A83-9191-501091BFC1AC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -71,6 +73,10 @@ Global {04F16325-8F44-4250-9B2E-0F77E8F65CBF}.Debug|Any CPU.Build.0 = Debug|Any CPU {04F16325-8F44-4250-9B2E-0F77E8F65CBF}.Release|Any CPU.ActiveCfg = Release|Any CPU {04F16325-8F44-4250-9B2E-0F77E8F65CBF}.Release|Any CPU.Build.0 = Release|Any CPU + {03F3C7D5-6FEF-4A83-9191-501091BFC1AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03F3C7D5-6FEF-4A83-9191-501091BFC1AC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03F3C7D5-6FEF-4A83-9191-501091BFC1AC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03F3C7D5-6FEF-4A83-9191-501091BFC1AC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -84,6 +90,7 @@ Global {5B89E22A-78CF-4037-BE0D-5F88EF47022B} = {C7821F15-DEDD-474F-A575-A296D4B58F10} {1D62101B-C2B1-4E79-8F7A-690E44E3EE81} = {C7821F15-DEDD-474F-A575-A296D4B58F10} {04F16325-8F44-4250-9B2E-0F77E8F65CBF} = {C7821F15-DEDD-474F-A575-A296D4B58F10} + {03F3C7D5-6FEF-4A83-9191-501091BFC1AC} = {C7821F15-DEDD-474F-A575-A296D4B58F10} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5BA73C3B-D5FC-4942-9091-504325CDC308} diff --git a/modules/ChatManagement/ChatManagement.cs b/modules/ChatManagement/ChatManagement.cs index 782feb6..8cb5e01 100644 --- a/modules/ChatManagement/ChatManagement.cs +++ b/modules/ChatManagement/ChatManagement.cs @@ -1,11 +1,10 @@ using Kruzya.TelegramBot.Core; -namespace West.TelegramBot.ChatManagement +namespace West.TelegramBot.ChatManagement; + +public class ChatManagement : Module { - public class ChatManagement : Module + public ChatManagement(Core core) : base(core) { - public ChatManagement(Core core) : base(core) - { - } } } \ No newline at end of file diff --git a/modules/ChatManagement/Handler/Ban.cs b/modules/ChatManagement/Handler/Ban.cs index f90d21d..2d96447 100644 --- a/modules/ChatManagement/Handler/Ban.cs +++ b/modules/ChatManagement/Handler/Ban.cs @@ -3,41 +3,41 @@ using System.Threading.Tasks; using BotFramework.Attributes; using BotFramework.Enums; +using Kruzya.TelegramBot.Core; using Kruzya.TelegramBot.Core.Data; using Telegram.Bot; using Telegram.Bot.Types; -namespace West.TelegramBot.ChatManagement.Handler +namespace West.TelegramBot.ChatManagement.Handler; + +public class Ban : CommonHandler { - public class Ban : ManagementHandler + public Ban(CoreContext db) : base(db) {} + + [Command("ban", CommandParseMode.Both)] + public async Task HandleBan() { - public Ban(CoreContext db) : base(db) {} + if (!await CanPunish()) return; - [Command("ban", CommandParseMode.Both)] - public async Task HandleBan() - { - if (!await CanPunish()) return; + await BanMember(RepliedUser!); + } - await BanMember(RepliedUser!); - } + [Command("unban", CommandParseMode.Both)] + public async Task HandleUnban() + { + if (!await CanPunish()) return; - [Command("unban", CommandParseMode.Both)] - public async Task HandleUnban() - { - if (!await CanPunish()) return; - - await Bot.RestrictChatMemberAsync(Chat.Id, RepliedUser!.Id, - new ChatPermissions - { - CanSendMessages = true, - CanChangeInfo = true, - CanInviteUsers = true, - CanPinMessages = true, - CanSendPolls = true, - CanSendMediaMessages = true, - CanSendOtherMessages = true, - CanAddWebPagePreviews = true - }); - } + await Bot.RestrictChatMemberAsync(Chat.Id, RepliedUser!.Id, + new ChatPermissions + { + CanSendMessages = true, + CanChangeInfo = true, + CanInviteUsers = true, + CanPinMessages = true, + CanSendPolls = true, + CanSendMediaMessages = true, + CanSendOtherMessages = true, + CanAddWebPagePreviews = true + }); } } \ No newline at end of file diff --git a/modules/ChatManagement/Handler/Reputation.cs b/modules/ChatManagement/Handler/Reputation.cs deleted file mode 100644 index d8e560c..0000000 --- a/modules/ChatManagement/Handler/Reputation.cs +++ /dev/null @@ -1,183 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using BotFramework.Attributes; -using BotFramework.Enums; -using BotFramework.Utils; -using Kruzya.TelegramBot.Core.Data; -using Kruzya.TelegramBot.Core.Extensions; -using Kruzya.TelegramBot.Core.Service; -using Microsoft.Extensions.Logging; -using Telegram.Bot; -using Telegram.Bot.Types; -using Telegram.Bot.Types.Enums; - -namespace West.TelegramBot.ChatManagement.Handler -{ - public class Reputation : ManagementHandler - { - private readonly UserService _userService; - private readonly ILogger _logger; - - private static readonly List RepUpChars = new() - { - "+", - "👍", - "👍🏼", - "👍🏽", - "👍🏾", - "👍🏿", - "👍🏻" - }; - private static readonly List RepDownChars = new() - { - "-", - "👎", - "👎🏼", - "👎🏽", - "👎🏾", - "👎🏿", - "👎🏻" - }; - - public Reputation(CoreContext db, UserService userService, ILogger logger) : base(db) - { - _userService = userService; - _logger = logger; - } - - [Command("whois", CommandParseMode.Both)] - public async Task HandleWhois() - { - var user = RepliedUser ?? From; - var message = $"{user.ToHtml()}\n"; - if (user.IsBot) - { - message += "Бот"; - } - else - { - message += $"{await GetChatMemberStatusString(user)}\n"; - message += $"Репутация: {await GetUserReputation(user)}\n"; - message += $"Предупреждения: {(await GetWarnOption(user)).GetValue()}"; - } - - await Reply(message); - } - - [Message(InChat.Public, MessageFlag.HasText | MessageFlag.HasSticker)] - public async Task HandleReputation() - { - var text = Message?.Text; - - if (string.IsNullOrEmpty(text)) - { - text = Message?.Sticker?.Emoji; - } - if (text == null) - { - return; - } - - var receiver = RepliedUser; - var canChangeRep = receiver is { IsBot: false } && !From.IsBot && receiver.Id != From.Id; - - if (canChangeRep && (RepUpChars.Contains(text) || RepDownChars.Contains(text))) - { - var receiverRepOption = await GetReputationOption(receiver!); - var receiverRepCount = receiverRepOption.GetValue(); - var senderRepCount = (await GetReputationOption(From)).GetValue(); - - if (senderRepCount < 0) - { - await Reply(new HtmlString() - .Code("Ты не можешь голосовать с отрицательной репутацией.") - .ToString() - ); - return; - } - - var diff = Math.Round(senderRepCount == 0 ? 1 : Math.Sqrt(senderRepCount), 2); - _logger.LogDebug("Calculated diff: {Diff}", diff); - - string action; - if (RepUpChars.Contains(text)) - { - receiverRepCount += diff; - action = "увеличил"; - } - else - { - receiverRepCount -= diff; - action = "уменьшил"; - } - - _logger.LogDebug("Saving receiver rep with value: {Value}", receiverRepCount); - receiverRepOption.SetValue(receiverRepCount); - Db.AddOrUpdate(receiverRepOption); - _logger.LogDebug("Updated value: {Value}", receiverRepOption.GetValue()); - - await Reply($"{From.ToHtml()}({await GetUserReputation(From)}) " + - $"{action} репутацию {receiver.ToHtml()}({Math.Round(receiverRepCount, 2)})"); - } - } - - [ParametrizedCommand(InChat.Public, "setrep", CommandParseMode.Both)] - public async Task HandleSetRep(double reputation) - { - if (!_userService.IsUserSuper(From)) - { - _logger.LogInformation("{User} attempted to call /setrep in {Chat}", From, Chat.Title); - return; - } - - if (RepliedUser == null || RepliedUser.IsBot) - { - return; - } - - reputation = Math.Round(reputation, 2); - var opt = await GetReputationOption(RepliedUser); - opt.SetValue(reputation); - Db.AddOrUpdate(opt); - - await Reply( - new HtmlString() - .Code("Пользователю ") - .UserMention(RepliedUser) - .Code($" установлена репутация: {reputation}") - .ToString() - ); - } - - private async Task GetUserReputation(User user) - { - return Math.Round((await GetReputationOption(user)).GetValue(), 2); - } - - private async Task GetReputationOption(User user) - { - return await Db.UserValues.FindOrCreateOption(Chat.Id, user.Id, "reputation"); - } - - private async Task GetChatMemberStatusString(User user) - { - var customStatus = _userService.GetUserCustomStatus(user.Id); - if (customStatus != null) - { - return customStatus; - } - - var chatMember = await Bot.GetChatMemberAsync(Chat.Id, user.Id); - return chatMember.Status switch - { - ChatMemberStatus.Creator => "Владелец", - ChatMemberStatus.Restricted => "Заблокированный", - ChatMemberStatus.Administrator => "Администратор", - _ => "Пользователь" - }; - } - } -} \ No newline at end of file diff --git a/modules/ChatManagement/Handler/Rules.cs b/modules/ChatManagement/Handler/Rules.cs index bbebfdd..b9be098 100644 --- a/modules/ChatManagement/Handler/Rules.cs +++ b/modules/ChatManagement/Handler/Rules.cs @@ -1,47 +1,47 @@ using System.Threading.Tasks; using BotFramework.Attributes; using BotFramework.Enums; +using Kruzya.TelegramBot.Core; using Kruzya.TelegramBot.Core.Data; using Kruzya.TelegramBot.Core.Extensions; using Telegram.Bot; using Telegram.Bot.Exceptions; -namespace West.TelegramBot.ChatManagement.Handler +namespace West.TelegramBot.ChatManagement.Handler; + +class Rules : CommonHandler { - class Rules : ManagementHandler + [Command(InChat.Public, "setrules", CommandParseMode.Both)] + public async Task HandleSetRules() { - [Command(InChat.Public, "setrules", CommandParseMode.Both)] - public async Task HandleSetRules() - { - var replyToMessage = Message?.ReplyToMessage; + var replyToMessage = Message?.ReplyToMessage; - if (replyToMessage == null || !await Bot.IsUserAdminAsync(Chat, From)) return; + if (replyToMessage == null || !await Bot.IsUserAdminAsync(Chat, From)) return; - var opt = await Db.UserValues.FindOrCreateOption(Chat.Id, "rulesMsgId"); - opt.SetValue(replyToMessage.MessageId); - Db.AddOrUpdate(opt); - } - - [Command(InChat.Public, "rules", CommandParseMode.Both)] - public async Task HandleRules() - { - var opt = await Db.UserValues.FindOption(Chat.Id, "rulesMsgId"); - if (opt == null) - { - return; - } - - try - { - await Bot.CopyMessageAsync(Chat.Id, Chat.Id, opt.GetValue()); - } - catch (ApiRequestException) - { - Db.UserValues.Remove(opt); - throw; - } - } - - public Rules(CoreContext db) : base(db) {} + var opt = await Db.UserValues.FindOrCreateOption(Chat.Id, "rulesMsgId"); + opt.SetValue(replyToMessage.MessageId); + Db.AddOrUpdate(opt); } + + [Command(InChat.Public, "rules", CommandParseMode.Both)] + public async Task HandleRules() + { + var opt = await Db.UserValues.FindOption(Chat.Id, "rulesMsgId"); + if (opt == null) + { + return; + } + + try + { + await Bot.CopyMessageAsync(Chat.Id, Chat.Id, opt.GetValue()); + } + catch (ApiRequestException) + { + Db.UserValues.Remove(opt); + throw; + } + } + + public Rules(CoreContext db) : base(db) {} } \ No newline at end of file diff --git a/modules/ChatManagement/Handler/Warn.cs b/modules/ChatManagement/Handler/Warn.cs index ee3d8a4..282ef39 100644 --- a/modules/ChatManagement/Handler/Warn.cs +++ b/modules/ChatManagement/Handler/Warn.cs @@ -4,59 +4,59 @@ using System; using System.Threading.Tasks; using BotFramework.Attributes; using BotFramework.Enums; +using Kruzya.TelegramBot.Core; using Kruzya.TelegramBot.Core.Data; using Kruzya.TelegramBot.Core.Extensions; using Telegram.Bot; using Telegram.Bot.Types.Enums; -namespace West.TelegramBot.ChatManagement.Handler +namespace West.TelegramBot.ChatManagement.Handler; + +public class Warn : CommonHandler { - public class Warn : ManagementHandler + public Warn(CoreContext db) : base(db) {} + + [Command("warn", CommandParseMode.Both)] + public async Task HandleWarn() { - public Warn(CoreContext db) : base(db) {} - - [Command("warn", CommandParseMode.Both)] - public async Task HandleWarn() + if (!await CanPunish()) return; + + var warnUser = RepliedUser!; + var warnOption = await GetWarnOption(warnUser); + var warnCount = warnOption!.GetValue() + 1; + warnOption.SetValue(warnCount); + Db.AddOrUpdate(warnOption); + + await Bot.SendTextMessageAsync(Chat.Id, $"{warnUser.ToHtml()}, Вам выдано предупреждение! " + + $"Текущее кол-во: {warnCount}/3", + disableNotification: true, + parseMode: ParseMode.Html); + + if (warnCount == 3) { - if (!await CanPunish()) return; - - var warnUser = RepliedUser!; - var warnOption = await GetWarnOption(warnUser); - var warnCount = warnOption!.GetValue() + 1; - warnOption.SetValue(warnCount); + await BanMember(warnUser); + warnOption.SetValue(0); Db.AddOrUpdate(warnOption); - - await Bot.SendTextMessageAsync(Chat.Id, $"{warnUser.ToHtml()}, Вам выдано предупреждение! " + - $"Текущее кол-во: {warnCount}/3", - disableNotification: true, - parseMode: ParseMode.Html); - - if (warnCount == 3) - { - await BanMember(warnUser); - warnOption.SetValue(0); - Db.AddOrUpdate(warnOption); - } - } - - [Command("unwarn", CommandParseMode.Both)] - public async Task HandleUnwarn() - { - if (!await CanPunish()) return; - - var warnOption = await GetWarnOption(RepliedUser!); - var warnCount = warnOption.GetValue(); - if (warnCount == 0) return; - - warnCount = Math.Max(warnCount - 1, 0); - warnOption.SetValue(warnCount); - Db.AddOrUpdate(warnOption); - - await Bot.SendTextMessageAsync(Chat.Id, - $"{RepliedUser.ToHtml()}, с Вас снято предупреждение! " + - $"Текущее кол-во: {warnCount}/3", - disableNotification: true, - parseMode: ParseMode.Html); } } + + [Command("unwarn", CommandParseMode.Both)] + public async Task HandleUnwarn() + { + if (!await CanPunish()) return; + + var warnOption = await GetWarnOption(RepliedUser!); + var warnCount = warnOption.GetValue(); + if (warnCount == 0) return; + + warnCount = Math.Max(warnCount - 1, 0); + warnOption.SetValue(warnCount); + Db.AddOrUpdate(warnOption); + + await Bot.SendTextMessageAsync(Chat.Id, + $"{RepliedUser.ToHtml()}, с Вас снято предупреждение! " + + $"Текущее кол-во: {warnCount}/3", + disableNotification: true, + parseMode: ParseMode.Html); + } } \ No newline at end of file diff --git a/modules/ChatManagement/Handler/WhoIs.cs b/modules/ChatManagement/Handler/WhoIs.cs new file mode 100644 index 0000000..2b38905 --- /dev/null +++ b/modules/ChatManagement/Handler/WhoIs.cs @@ -0,0 +1,67 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using BotFramework.Attributes; +using BotFramework.Enums; +using Kruzya.TelegramBot.Core; +using Kruzya.TelegramBot.Core.Data; +using Kruzya.TelegramBot.Core.Extensions; +using Kruzya.TelegramBot.Core.Service; +using Microsoft.Extensions.DependencyInjection; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace West.TelegramBot.ChatManagement.Handler; + +public class WhoIs : CommonHandler +{ + private readonly IReputation? _reputation; + private readonly UserService _userService; + + public WhoIs(CoreContext db, UserService userService, IServiceProvider provider) : base(db) + { + _reputation = provider.GetService(); + _userService = userService; + } + + [Command("whois", CommandParseMode.Both)] + public async Task HandleWhoIs() + { + var user = RepliedUser ?? From; + var message = $"{user.ToHtml()}\n"; + if (user.IsBot) + { + message += "Бот"; + } + else + { + message += $"{await GetChatMemberStatusString(user)}\n"; + if (_reputation != null) + { + message += $"Репутация: {await _reputation.GetUserReputationAsync(Chat, user)}\n"; + } + message += $"Предупреждения: {(await GetWarnOption(user)).GetValue()}"; + } + + await Reply(message); + } + + private async Task GetChatMemberStatusString(User user) + { + var customStatus = _userService.GetUserCustomStatus(user.Id); + if (customStatus != null) + { + return customStatus; + } + + var chatMember = await Bot.GetChatMemberAsync(Chat.Id, user.Id); + return chatMember.Status switch + { + ChatMemberStatus.Creator => "Владелец", + ChatMemberStatus.Restricted => "Заблокированный", + ChatMemberStatus.Administrator => "Администратор", + _ => "Пользователь" + }; + } +} \ No newline at end of file diff --git a/modules/Reputation/Data/ReputationContext.cs b/modules/Reputation/Data/ReputationContext.cs new file mode 100644 index 0000000..c93f941 --- /dev/null +++ b/modules/Reputation/Data/ReputationContext.cs @@ -0,0 +1,20 @@ +using Kruzya.TelegramBot.Core.EF; +using Microsoft.EntityFrameworkCore; + +namespace West.TelegramBot.Reputation.Data; + +public class ReputationContext : AutoSaveDbContext +{ + public DbSet UserReputation { get; set; } = null!; + + public ReputationContext(DbContextOptions options) : base(options) + { + Database.EnsureCreated(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(e => new {e.ChatId, e.UserId}); + modelBuilder.Entity().HasIndex(e => e.ChatId); + } +} \ No newline at end of file diff --git a/modules/Reputation/Data/UserReputation.cs b/modules/Reputation/Data/UserReputation.cs new file mode 100644 index 0000000..3b06cf8 --- /dev/null +++ b/modules/Reputation/Data/UserReputation.cs @@ -0,0 +1,10 @@ +using Kruzya.TelegramBot.Core.Data; + +namespace West.TelegramBot.Reputation.Data; + +public class UserReputation : IReputationEntity +{ + public long UserId { get; set; } + public long ChatId { get; set; } + public double Value { get; set; } +} \ No newline at end of file diff --git a/modules/Reputation/Data/UserReputationContextFactory.cs b/modules/Reputation/Data/UserReputationContextFactory.cs new file mode 100644 index 0000000..50f8b23 --- /dev/null +++ b/modules/Reputation/Data/UserReputationContextFactory.cs @@ -0,0 +1,21 @@ +#nullable enable +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace West.TelegramBot.Reputation.Data; + +public class ReputationContextFactory : IDesignTimeDbContextFactory +{ + public ReputationContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + if (args.Length != 1) + { + throw new InvalidDataException("You should pass DSN as CLI argument in order to use this."); + } + var dsn = args[0]; + optionsBuilder.UseMySql(dsn, ServerVersion.AutoDetect(dsn)); + + return new ReputationContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/modules/Reputation/Handler/Debug.cs b/modules/Reputation/Handler/Debug.cs new file mode 100644 index 0000000..c9c3962 --- /dev/null +++ b/modules/Reputation/Handler/Debug.cs @@ -0,0 +1,64 @@ +using BotFramework; +using BotFramework.Attributes; +using BotFramework.Enums; +using Kruzya.TelegramBot.Core.Data; +using Kruzya.TelegramBot.Core.Service; +using Microsoft.EntityFrameworkCore; +using West.TelegramBot.Reputation.Data; + +namespace West.TelegramBot.Reputation.Handler; + +public class Debug : BotEventHandler +{ + private readonly CoreContext _coreContext; + private readonly ReputationContext _reputationContext; + private readonly UserService _userService; + + + public Debug(CoreContext coreContext, ReputationContext reputationContext, UserService userService) + { + _coreContext = coreContext; + _reputationContext = reputationContext; + _userService = userService; + } + + [Command(InChat.Public, "dbg_migrate", CommandParseMode.Both)] + public async Task HandleMigrate() + { + if (!_userService.IsUserSuper(From)) + { + return; + } + + var reputationValues = _coreContext.UserValues.AsNoTracking() + .Where(value => value.Name == "reputation").Include(v => v.BotUser); + + foreach (var reputationValue in reputationValues) + { + var value = reputationValue.GetValue(); + if (value == 0D) + { + continue; + } + + var userReputation = new UserReputation + { + ChatId = reputationValue.BotUser.ChatId, + UserId = reputationValue.BotUser.UserId, + Value = value + }; + + var repList = _reputationContext.UserReputation; + if (repList.Contains(userReputation)) + { + repList.Update(userReputation); + } + else + { + repList.Add(userReputation); + } + } + + await _reputationContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/modules/Reputation/Handler/Reputation.cs b/modules/Reputation/Handler/Reputation.cs new file mode 100644 index 0000000..590076d --- /dev/null +++ b/modules/Reputation/Handler/Reputation.cs @@ -0,0 +1,151 @@ +#nullable enable + +using BotFramework.Attributes; +using BotFramework.Enums; +using BotFramework.Utils; +using Kruzya.TelegramBot.Core; +using Kruzya.TelegramBot.Core.Data; +using Kruzya.TelegramBot.Core.Extensions; +using Kruzya.TelegramBot.Core.Service; +using Microsoft.Extensions.Logging; +using Telegram.Bot.Types; + +namespace West.TelegramBot.Reputation.Handler; + +public class Reputation : CommonHandler +{ + private readonly UserService _userService; + private readonly ILogger _logger; + private readonly IReputation _reputationService; + + private static readonly List RepUpChars = new() + { + "+", + "👍", + "👍🏼", + "👍🏽", + "👍🏾", + "👍🏿", + "👍🏻" + }; + private static readonly List RepDownChars = new() + { + "-", + "👎", + "👎🏼", + "👎🏽", + "👎🏾", + "👎🏿", + "👎🏻" + }; + + public Reputation( + CoreContext db, + UserService userService, + IReputation reputationService, + ILogger logger) : base(db) + { + _reputationService = reputationService; + _userService = userService; + _logger = logger; + } + + [Message(InChat.Public, MessageFlag.HasText | MessageFlag.HasSticker)] + public async Task HandleReputation() + { + var text = Message?.Text; + + if (string.IsNullOrEmpty(text)) + { + text = Message?.Sticker?.Emoji; + } + if (text == null) + { + return; + } + + var receiver = RepliedUser; + var canChangeRep = receiver is { IsBot: false } && !From.IsBot && receiver.Id != From.Id; + + if (canChangeRep && (RepUpChars.Contains(text) || RepDownChars.Contains(text))) + { + var senderRepCount = await GetUserReputation(From); + + if (senderRepCount < 0) + { + await Reply(new HtmlString() + .Code("Ты не можешь голосовать с отрицательной репутацией.") + .ToString() + ); + return; + } + + var diff = await _reputationService.GetReputationDiffAsync(Chat, From, receiver); + _logger.LogDebug("Calculated diff: {Diff}", diff); + + string action; + if (RepUpChars.Contains(text)) + { + action = "увеличил"; + } + else + { + diff *= -1; + action = "уменьшил"; + } + + await _reputationService.IncrementReputationAsync(Chat, receiver, diff); + + var receiverRepCount = await GetUserReputation(receiver!); + _logger.LogDebug("Updated value: {Value}", receiverRepCount); + + await Reply($"{From.ToHtml()}({await GetUserReputation(From)}) " + + $"{action} репутацию {receiver.ToHtml()}({Math.Round(receiverRepCount, 2)})"); + } + } + + [ParametrizedCommand(InChat.Public, "setrep", CommandParseMode.Both)] + public async Task HandleSetRep(double reputation) + { + if (!_userService.IsUserSuper(From)) + { + _logger.LogInformation("{User} attempted to call /setrep in {Chat}", From, Chat.Title); + return; + } + + if (RepliedUser == null || RepliedUser.IsBot) + { + return; + } + + reputation = await _reputationService.SetUserReputationAsync(Chat, RepliedUser, reputation); + await Reply( + new HtmlString() + .Code("Пользователю ") + .UserMention(RepliedUser) + .Code($" установлена репутация: {reputation}") + .ToString() + ); + } + + [Command(InChat.Public, "top", CommandParseMode.Both)] + public async Task HandleTop() + { + var msg = new HtmlString(); + msg.TextBr("Топ репутации чата: "); + var i = 1; + foreach (var rep in await _reputationService.GetChatRatingAsync(Chat)) + { + var user = await _userService.GetUser(rep.UserId, rep.ChatId); + msg.Text($"{i}. ").UserMention(user).Text($" ({rep.Value})").Br(); + i++; + } + + await Bot.SendHtmlStringAsync(Chat, msg); + } + + private async Task GetUserReputation(User user) + { + return await _reputationService.GetUserReputationAsync(Chat, user); + } +} \ No newline at end of file diff --git a/modules/Reputation/Migrations/20220213182703_Initial.Designer.cs b/modules/Reputation/Migrations/20220213182703_Initial.Designer.cs new file mode 100644 index 0000000..f2f58b8 --- /dev/null +++ b/modules/Reputation/Migrations/20220213182703_Initial.Designer.cs @@ -0,0 +1,43 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using West.TelegramBot.Reputation.Data; + +#nullable disable + +namespace West.TelegramBot.Reputation.Migrations +{ + [DbContext(typeof(ReputationContext))] + [Migration("20220213182703_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("West.TelegramBot.ChatManagement.Data.UserReputation", b => + { + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasColumnType("double"); + + b.HasKey("ChatId", "UserId"); + + b.HasIndex("ChatId"); + + b.ToTable("UserReputation"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/modules/Reputation/Migrations/20220213182703_Initial.cs b/modules/Reputation/Migrations/20220213182703_Initial.cs new file mode 100644 index 0000000..85524a7 --- /dev/null +++ b/modules/Reputation/Migrations/20220213182703_Initial.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace West.TelegramBot.Reputation.Migrations; + +public partial class Initial : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "UserReputation", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + ChatId = table.Column(type: "bigint", nullable: false), + Value = table.Column(type: "double", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserReputation", x => new { x.ChatId, x.UserId }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_UserReputation_ChatId", + table: "UserReputation", + column: "ChatId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserReputation"); + } +} \ No newline at end of file diff --git a/modules/Reputation/Migrations/ReputationContextModelSnapshot.cs b/modules/Reputation/Migrations/ReputationContextModelSnapshot.cs new file mode 100644 index 0000000..ee5bd09 --- /dev/null +++ b/modules/Reputation/Migrations/ReputationContextModelSnapshot.cs @@ -0,0 +1,39 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using West.TelegramBot.Reputation.Data; + +#nullable disable + +namespace West.TelegramBot.Reputation.Migrations; + +[DbContext(typeof(ReputationContext))] +partial class ReputationContextModelSnapshot : ModelSnapshot +{ + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("West.TelegramBot.ChatManagement.Data.UserReputation", b => + { + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasColumnType("double"); + + b.HasKey("ChatId", "UserId"); + + b.HasIndex("ChatId"); + + b.ToTable("UserReputation"); + }); +#pragma warning restore 612, 618 + } +} \ No newline at end of file diff --git a/modules/Reputation/Reputation.cs b/modules/Reputation/Reputation.cs new file mode 100644 index 0000000..ff524b8 --- /dev/null +++ b/modules/Reputation/Reputation.cs @@ -0,0 +1,24 @@ +using Kruzya.TelegramBot.Core; +using Kruzya.TelegramBot.Core.Service; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using West.TelegramBot.Reputation.Data; +using West.TelegramBot.Reputation.Service; + +namespace West.TelegramBot.Reputation; + +public class Reputation : Module +{ + public Reputation(Core core) : base(core) + { + } + + public override void ConfigureServices(IServiceCollection services) + { + var dsn = Configuration.GetConnectionString("Reputation"); + services.AddDbContext(options => options.UseMySql(dsn, ServerVersion.AutoDetect(dsn))); + + services.AddScoped(); + } +} \ No newline at end of file diff --git a/modules/Reputation/Reputation.csproj b/modules/Reputation/Reputation.csproj new file mode 100644 index 0000000..7d3549f --- /dev/null +++ b/modules/Reputation/Reputation.csproj @@ -0,0 +1,29 @@ + + + + net6.0 + enable + enable + TelegramBot.Reputation + West.TelegramBot.Reputation + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/modules/Reputation/Service/ReputationService.cs b/modules/Reputation/Service/ReputationService.cs new file mode 100644 index 0000000..d3877b0 --- /dev/null +++ b/modules/Reputation/Service/ReputationService.cs @@ -0,0 +1,73 @@ +using Kruzya.TelegramBot.Core.Data; +using Kruzya.TelegramBot.Core.Extensions; +using Kruzya.TelegramBot.Core.Service; +using Microsoft.EntityFrameworkCore; +using Telegram.Bot.Types; +using West.TelegramBot.Reputation.Data; + +namespace West.TelegramBot.Reputation.Service; + +public class ReputationService : IReputation +{ + private readonly ReputationContext _reputationContext; + + public ReputationService(ReputationContext reputationContext) + { + _reputationContext = reputationContext; + } + + public async Task GetUserReputationAsync(Chat chat, User user) + { + var rep = await FindUserReputation(chat, user); + + return rep?.Value ?? 0; + } + + public async Task SetUserReputationAsync(Chat chat, User user, double value) + { + var rep = await FindUserReputation(chat, user) ?? new UserReputation + { + ChatId = chat.Id, + UserId = user.Id + }; + + rep.Value = Math.Round(value, 2); + _reputationContext.AddOrUpdate(rep); + + return rep.Value; + } + + public async Task IncrementReputationAsync(Chat chat, User user, double diff) + { + await SetUserReputationAsync(chat, user, await GetUserReputationAsync(chat, user) + diff); + } + + public async Task GetReputationDiffAsync(Chat chat, User sender, User receiver) + { + var senderRep = await FindUserReputation(chat, sender); + + if (senderRep == null) + { + return 1; + } + + var senderValue = senderRep.Value; + return Math.Round(senderValue == 0 ? 1 : Math.Sqrt(senderValue), 2); + } + + public async Task> GetChatRatingAsync(Chat chat, int limit = 10, int offset = 0) + { + return await _reputationContext.UserReputation + .Where(r => r.ChatId == chat.Id && r.Value > 0D) + .OrderByDescending(r => r.Value) + .Skip(offset) + .Take(limit) + .ToListAsync(); + } + + private async Task FindUserReputation(Chat chat, User user) + { + return await _reputationContext.UserReputation.SingleOrDefaultAsync( + e => e.ChatId == chat.Id && e.UserId == user.Id); + } +} \ No newline at end of file