diff --git a/modules/RichSiteSummary/Data/Feed.cs b/modules/RichSiteSummary/Data/Feed.cs new file mode 100644 index 0000000..e63dc17 --- /dev/null +++ b/modules/RichSiteSummary/Data/Feed.cs @@ -0,0 +1,84 @@ +/// This file is a part of RSS Bot for Telegram. +/// License: MIT +/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) +/// + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Kruzya.TelegramBot.RichSiteSummary.Data +{ + /// + /// Represents a feed in database. + /// + public class Feed : RepresentableEntity + { + #region RepresentableEntity + protected override string ViewableText + { + get => Title; + } + + protected override string ViewableUrl + { + get => HomePage; + } + #endregion + + /// + /// The unique feed id. + /// + [Key] + public Guid FeedId { get; set; } + + /// + /// The feed URL. + /// + [Required] + public string Url { get; set; } + + /// + /// The home page for this feed. + /// + [Required] + public string HomePage { get; set; } + + /// + /// Feed title. + /// + [MaxLength(256)] + [Required] + public string Title { get; set; } + + /// + /// DateTime when feed fetched successfully in last time. + /// + [Required] + public DateTime UpdatedAt { get; set; } + + /// + /// This flag indicates, bot should monitor changes in post date, or not. + /// + [Required] + [DefaultValue(false)] + public bool WatchPostDate { get; set; } + + public Feed() + { + FeedId = new Guid(); + UpdatedAt = DateTime.Now; + } + + /// + /// All posts from this feed. + /// + public virtual ICollection Posts { get; set; } + + /// + /// All exists subscriber for this feed. + /// + public virtual ICollection Subscribers { get; set; } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Data/Post.cs b/modules/RichSiteSummary/Data/Post.cs new file mode 100644 index 0000000..2fe2c24 --- /dev/null +++ b/modules/RichSiteSummary/Data/Post.cs @@ -0,0 +1,98 @@ +/// This file is a part of RSS Bot for Telegram. +/// License: MIT +/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) +/// + +using System; +using System.ComponentModel.DataAnnotations; +using System.Text; +using Telegram.Bot.Types.Enums; + +namespace Kruzya.TelegramBot.RichSiteSummary.Data +{ + /// + /// Represents a post from feed in database. + /// + public class Post : RepresentableEntity + { + #region RepresentableEntity + protected override ParseMode DefaultRepresentation + { + get => ParseMode.Html; + } + + protected override string ViewableText + { + get => Title; + } + + protected override string ViewableUrl + { + get => Url; + } + #endregion + + /// + /// The unique post id. + /// + [Key] + public Guid PostId { get; set; } + + /// + /// The feed unique identifier from where this post is fetched. + /// + public Guid FeedId { get; set; } + + /// + /// The feed from where this post is fetched. + /// + [Required] + public virtual Feed Feed { get; set; } + + /// + /// The post title. + /// + [MaxLength(256)] + [Required] + public string Title { get; set; } + + /// + /// The URI where this post is located in web. + /// + [Required] + public string Url { get; set; } + + /// + /// DateTime when post is created in RSS feed. + /// + [Required] + public DateTime PostedAt { get; set; } + + /// + /// DateTime when post is fetched/updated in database. + /// + [Required] + public DateTime ReceivedAt { get; set; } + + public Post() + { + PostId = new Guid(); + ReceivedAt = DateTime.Now; + } + + public string MessageText + { + get + { + var message = new StringBuilder(); + message.Append($"{Representation("🗞")} New on {Feed.Representation(ParseMode.Html)}\n"); + message.Append("\n"); + message.Append(Title); + message.Append("\n"); + message.Append(Representation("Open in browser")); + + return message.ToString(); + } + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Data/RepresentableEntity.cs b/modules/RichSiteSummary/Data/RepresentableEntity.cs new file mode 100644 index 0000000..d8086a3 --- /dev/null +++ b/modules/RichSiteSummary/Data/RepresentableEntity.cs @@ -0,0 +1,65 @@ +/// This file is a part of RSS Bot for Telegram. +/// License: MIT +/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) +/// + +using System; +using System.Web; +using Telegram.Bot.Types.Enums; + +namespace Kruzya.TelegramBot.RichSiteSummary.Data +{ + /// + /// Implements the basic logic for rendering Database Entities in messages. + /// + public abstract class RepresentableEntity + { + protected virtual string ViewableText { get; } + protected virtual string ViewableUrl { get; } + protected virtual ParseMode DefaultRepresentation { get; } + + public override string ToString() => Representation(); + + public string Representation(string text) => Representation(DefaultRepresentation, text); + + public string Representation() => Representation(DefaultRepresentation); + + public string Representation(ParseMode parseMode) => Representation(parseMode, ViewableText); + + public string Representation(ParseMode parseMode, string text) + { + string content = String.Empty; + switch (parseMode) + { + case ParseMode.Default: + content = RawRepresentation(text); + break; + + case ParseMode.Html: + content = HtmlRepresentation(text); + break; + + case ParseMode.Markdown: + case ParseMode.MarkdownV2: + throw new NotImplementedException(); + } + + return content; + } + + #region Representations + + public string RawRepresentation(string text) + => text; + + public string HtmlRepresentation(string text) + { + var escapedText = HttpUtility.HtmlEncode(text); + var escapedUrl = HttpUtility.HtmlAttributeEncode(ViewableUrl); + + return $"{escapedText}"; + } + + #endregion + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Data/RichSiteSummaryContext.cs b/modules/RichSiteSummary/Data/RichSiteSummaryContext.cs new file mode 100644 index 0000000..6b7328c --- /dev/null +++ b/modules/RichSiteSummary/Data/RichSiteSummaryContext.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; + +namespace Kruzya.TelegramBot.RichSiteSummary.Data +{ + public class RichSiteSummaryContext : DbContext + { + /// + /// Repository with all feeds in database. + /// + public DbSet Feeds { get; set; } + + /// + /// Repository with all posts in database. + /// + public DbSet Posts { get; set; } + + /// + /// Repository with all subscriptions in database. + /// + public DbSet Subscriptions { get; set; } + + /// + /// Ensures the database is created and calls parent constructor. + /// + /// + public RichSiteSummaryContext(DbContextOptions options) : base(options) + { + Database.EnsureCreated(); + } + + /// + /// Setup the additional unique keys. + /// + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Setup unique keys for Feed entity. + modelBuilder.Entity().HasIndex(feed => feed.Url).IsUnique(); + modelBuilder.Entity().HasIndex(feed => feed.HomePage).IsUnique(); + + // Setup unique keys for Post entity. + modelBuilder.Entity() + .HasOne(p => p.Feed) + .WithMany(f => f.Posts) + .HasForeignKey(p => p.FeedId); + + modelBuilder.Entity().HasIndex(post => + new {post.FeedId, post.Url}).IsUnique(); + + // Setup foreign keys for Subscriber entity. + modelBuilder.Entity() + .HasOne(s => s.Feed) + .WithMany(f => f.Subscribers) + .HasForeignKey(s => s.FeedId); + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Data/Subscriber.cs b/modules/RichSiteSummary/Data/Subscriber.cs new file mode 100644 index 0000000..364a8a1 --- /dev/null +++ b/modules/RichSiteSummary/Data/Subscriber.cs @@ -0,0 +1,56 @@ +/// This file is a part of RSS Bot for Telegram. +/// License: MIT +/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) +/// + +using System; +using System.ComponentModel.DataAnnotations; + +namespace Kruzya.TelegramBot.RichSiteSummary.Data +{ + /// + /// Represents a subscription in database. + /// + public class Subscriber + { + /// + /// The unique Subscription identifier. + /// + [Key] + public Guid SubscriptionId { get; set; } + + /// + /// The Telegram subscriber id. + /// + [Required] + public Int64 SubscriberId { get; set; } + + /// + /// Unique feed identifier related with this Subscription. Identifies what user is read. + /// + public Guid FeedId { get; set; } + + /// + /// Feed related with this Subscription. Identifies what user is read. + /// + [Required] + public virtual Feed Feed { get; set; } + + /// + /// When user is subscribed on this feed. + /// + [Required] + public DateTime SubscribedAt { get; set; } + + public Subscriber() + { + SubscriptionId = new Guid(); + SubscribedAt = DateTime.Now; + } + + public Subscriber(Feed feed) : base() + { + Feed = feed; + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Handler/AbstractHandler.cs b/modules/RichSiteSummary/Handler/AbstractHandler.cs new file mode 100644 index 0000000..71ee2f7 --- /dev/null +++ b/modules/RichSiteSummary/Handler/AbstractHandler.cs @@ -0,0 +1,15 @@ +using BotFramework; +using Kruzya.TelegramBot.RichSiteSummary.Data; + +namespace Kruzya.TelegramBot.RichSiteSummary.Handler +{ + public abstract class AbstractHandler : BotEventHandler + { + protected readonly RichSiteSummaryContext _dbContext; + + protected AbstractHandler(RichSiteSummaryContext dbContext) + { + _dbContext = dbContext; + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Handler/FeedList.cs b/modules/RichSiteSummary/Handler/FeedList.cs new file mode 100644 index 0000000..8ea8ffb --- /dev/null +++ b/modules/RichSiteSummary/Handler/FeedList.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BotFramework.Attributes; +using Kruzya.TelegramBot.Core.Extensions; +using Kruzya.TelegramBot.RichSiteSummary.Data; +using Microsoft.EntityFrameworkCore; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace Kruzya.TelegramBot.RichSiteSummary.Handler +{ + public class FeedList : AbstractHandler + { + public FeedList(RichSiteSummaryContext dbContext) : base(dbContext) + { + } + + [ParametrizedCommand("feed_show")] + public async Task ShowFeed(Feed feed) => await ShowFeed(feed, Chat, From); + + public async Task ShowFeed(Guid feedId, Chat chat, User from) + { + var feed = await _dbContext.FindAsync(feedId); + if (feed == null) + { + return; + } + + await ShowFeed(feed, chat, from); + } + + public async Task ShowFeed(Feed feed, Chat chat, User from, Message message = null) + { + var textBuilder = new StringBuilder(); + textBuilder.Append(feed.Representation(ParseMode.Html)); + textBuilder.Append("\n"); + textBuilder.Append($"Posts at this moment: {feed.Posts.Count}\n"); + textBuilder.Append($"RSS feed: {feed.Url}\n"); + textBuilder.Append($"Home page: {feed.HomePage}\n"); + textBuilder.Append($"Last synchronization: {feed.UpdatedAt}"); + + var text = textBuilder.ToString(); + + var buttons = new List(); + var callbackData = $"feed|{from.Id}|do|{feed.FeedId}"; + if (await _dbContext.Subscriptions.HasSubscription(chat, feed)) + { + buttons.Add(new InlineKeyboardButton() + { + CallbackData = callbackData, + Text = "Unsubscribe" + }); + } + else + { + buttons.Add(new InlineKeyboardButton() + { + CallbackData = callbackData, + Text = "Subscribe" + }); + } + buttons.Add(new InlineKeyboardButton() + { + CallbackData = $"feed|{from.Id}|last|{feed.FeedId}", + Text = "Last 10 posts" + }); + + var inlineButtons = new InlineKeyboardMarkup(buttons); + if (message == null) + { + await Bot.SendTextMessageAsync(new ChatId(chat.Id), text, ParseMode.Html, true, true, 0, inlineButtons); + } + else + { + await Bot.EditMessageTextAsync(chat, message.MessageId, text, ParseMode.Html, true, inlineButtons); + } + } + + [ParametrizedCommand("feed_list")] + public async Task List() => await GenerateMenu(); + + [ParametrizedCommand("feed_search")] + public async Task Search(string query) + { + if (!(await GenerateMenu(query))) + { + await Bot.SendTextMessageAsync(Chat, $"No one feed match by search pattern ({query}).", ParseMode.Html, true); + } + } + + [Command("feed_subscriptions")] + public async Task Subscriptions() + { + if (!(await GenerateMenu(":sub"))) + { + await Bot.SendTextMessageAsync(Chat, + "There are no one subscription. Add new with /feed_list or /feed_search."); + } + } + + #region Message generator + + protected async Task GenerateMenu() => + await GenerateMenu(string.Empty); + + protected async Task GenerateMenu(string query) => + await GenerateMenu(query, 0); + + protected async Task GenerateMenu(string query, UInt16 page, Message editableMessage = null) + { + var chat = editableMessage != null ? (editableMessage.Chat) : Chat; + var elementsOnPage = 9; + var startIndex = page * elementsOnPage; + var dbQuery = _dbContext.Feeds.AsQueryable(); + if (!string.IsNullOrWhiteSpace(query)) + { + var searchQuery = query; + if (query.EndsWith(":sub")) + { + var chatList = await _dbContext.Subscriptions.AllSubscriptions(chat).Select(chat => chat.Feed.FeedId).ToArrayAsync(); + dbQuery = dbQuery.Where(sub => chatList.Contains(sub.FeedId)); + + searchQuery = searchQuery.Remove(searchQuery.Length - 4, 4); + } + + dbQuery = dbQuery.Where(feed => feed.Title.Contains(searchQuery) || feed.Url.Contains(searchQuery) || feed.HomePage.Contains(searchQuery)); + } + + var totalCount = await dbQuery.CountAsync(); + if (totalCount == 0) + { + return false; + } + + if (totalCount == 1) + { + await ShowFeed((await dbQuery.FirstOrDefaultAsync()), Chat, From); + return true; + } + + var result = await dbQuery.Take(elementsOnPage * (page + 1)).Skip(startIndex) + .ToArrayAsync(); + + var backButton = (page > 0); + var nextButton = totalCount >= (startIndex + elementsOnPage); + + var buttons = new List>(); + List row; + foreach (var feed in result) + { + row = new List(); + row.Add(new InlineKeyboardButton() + { + CallbackData = $"feed|{From.Id}|show|{feed.FeedId.ToString()}", + Text = feed.Title + }); + + buttons.Add(row); + } + + if (backButton || nextButton) + { + row = new List(); + if (backButton) + { + row.Add(new InlineKeyboardButton() + { + CallbackData = $"feed|{From.Id}|page|{query}|{page - 1}", + Text = "⬅️" + }); + } + + row.Add(new InlineKeyboardButton() + { + CallbackData = $"feed|{From.Id}|donothing", + Text = $"{page + 1}/{(totalCount / elementsOnPage) + 1}" + }); + + if (nextButton) + { + row.Add(new InlineKeyboardButton() + { + CallbackData = $"feed|{From.Id}|page|{query}|{page + 1}", + Text = "➡️" + }); + } + + buttons.Add(row); + } + + if (editableMessage == null) + { + await Bot.SendTextMessageAsync(Chat, "Select the feed for viewing details", ParseMode.Default, + true, true, 0, new InlineKeyboardMarkup(buttons)); + } + else + { + await Bot.EditMessageTextAsync(editableMessage.Chat, editableMessage.MessageId, "Select the feed for viewing details", ParseMode.Default, false, + new InlineKeyboardMarkup(buttons)); + } + + return true; + } + + #endregion + + #region Message handler + + [CallbackQuery] + public async Task HandleAction() + { + if (!RawUpdate.CallbackQuery.Data.StartsWith("feed|")) + { + return; + } + + var data = RawUpdate.CallbackQuery.Data.Split('|'); + var fromId = Int64.Parse(data[1]); + if (fromId != From.Id) + { + await Bot.AnswerCallbackQueryAsync(RawUpdate.CallbackQuery.Id, + "You can't perform this action: command called by another user.", true); + return; + } + + // Check action. + switch (data[2]) + { + case "page": + await GenerateMenu(data[3], UInt16.Parse(data[4]), RawUpdate.CallbackQuery.Message); + break; + + case "show": + await ShowFeed(Guid.Parse(data[3]), RawUpdate.CallbackQuery.Message.Chat, RawUpdate.CallbackQuery.From); + break; + + case "do": + if (RawUpdate.CallbackQuery.From.Id != RawUpdate.CallbackQuery.Message.Chat.Id) + { + var userStatus = (await Bot.GetChatMemberAsync(RawUpdate.CallbackQuery.Message.Chat, RawUpdate.CallbackQuery.From.Id)).Status; + var allowedUserStatuses = new ChatMemberStatus[] + {ChatMemberStatus.Administrator, ChatMemberStatus.Creator}; + + if (!allowedUserStatuses.Contains(userStatus)) + { + await Bot.AnswerCallbackQueryAsync(RawUpdate.CallbackQuery.Id, + "You can't perform this action: you don't have administrator permissions.", true); + return; + } + } + + await ChangeFeedSubscription(Guid.Parse(data[3]), RawUpdate.CallbackQuery.Message.Chat, RawUpdate.CallbackQuery.From, RawUpdate.CallbackQuery.Message); + break; + + case "last": + var feed = await _dbContext.FindAsync(Guid.Parse(data[3])); + var posts = feed.Posts.OrderByDescending(p => p.ReceivedAt).Take(10).ToList(); + + var messageTextBuilder = new StringBuilder(); + messageTextBuilder.Append($"Last 10 posts from {feed.Representation(ParseMode.Html)}\n"); + messageTextBuilder.Append("\n"); + + var idx = 1; + foreach (var post in posts) + { + messageTextBuilder.Append($"{idx}. {post}\n"); + idx++; + } + + await Bot.SendTextMessageAsync(RawUpdate.CallbackQuery.Message.Chat, messageTextBuilder.ToString(), + ParseMode.Html, true); + break; + } + + await Bot.AnswerCallbackQueryAsync(RawUpdate.CallbackQuery.Id); + } + + protected async Task ChangeFeedSubscription(Guid feedId, Chat chat, User from, Message message) + { + var feed = await _dbContext.FindAsync(feedId); + if (feed == null) + { + return; + } + + var subscriptionState = await _dbContext.Subscriptions.HasSubscription(chat, feed); + if (subscriptionState) + { + var subId = await _dbContext.Subscriptions + .Where(subscriber => subscriber.Feed == feed && subscriber.SubscriberId == chat.Id) + .Select(subscriber => subscriber.SubscriptionId).FirstAsync(); + + _dbContext.MarkAsDeleted(_dbContext.Find(subId)); + } + else + { + var subscription = _dbContext.Subscriptions.Create(); + subscription.Feed = feed; + subscription.SubscriberId = chat.Id; + + _dbContext.MarkAsCreated(subscription); + } + + await _dbContext.SaveChangesAsync(); + await ShowFeed(feed, chat, from, message); + } + + #endregion + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Handler/StatsHandler.cs b/modules/RichSiteSummary/Handler/StatsHandler.cs new file mode 100644 index 0000000..6c0e5cf --- /dev/null +++ b/modules/RichSiteSummary/Handler/StatsHandler.cs @@ -0,0 +1,36 @@ +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BotFramework.Attributes; +using Kruzya.TelegramBot.RichSiteSummary.Data; +using Microsoft.EntityFrameworkCore; +using Telegram.Bot.Types.Enums; + +namespace Kruzya.TelegramBot.RichSiteSummary.Handler +{ + public class StatsHandler : AbstractHandler + { + public StatsHandler(RichSiteSummaryContext dbContext) : base(dbContext) + { + } + + [Command("feed_stats")] + public async Task Execute() + { + var feedCount = await _dbContext.Feeds.CountAsync(); + var postsCount = await _dbContext.Posts.CountAsync(); + var subscriptionsCount = await _dbContext.Subscriptions.CountAsync(); + var uniqueSubscribers = + await _dbContext.Subscriptions.Select(sub => sub.SubscriberId).Distinct().CountAsync(); + + var textMessage = new StringBuilder(); + textMessage.Append("ℹ️ In database on this moment:\n"); + textMessage.Append($"- registered {feedCount} feeds\n"); + textMessage.Append($"- saved {postsCount} posts for prevent re-sending\n"); + textMessage.Append( + $"- exists {subscriptionsCount} subscriptions on feeds (unique subscribers: {uniqueSubscribers})"); + + await Bot.SendTextMessageAsync(Chat, textMessage.ToString(), ParseMode.Html); + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/RichSiteSummary.csproj b/modules/RichSiteSummary/RichSiteSummary.csproj new file mode 100644 index 0000000..c882e53 --- /dev/null +++ b/modules/RichSiteSummary/RichSiteSummary.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp3.1 + TelegramBot.RichSiteSummary + Kruzya.TelegramBot.RichSiteSummary + + + + + + + + + + + diff --git a/modules/RichSiteSummary/RichSiteSummaryRepositoryExtension.cs b/modules/RichSiteSummary/RichSiteSummaryRepositoryExtension.cs new file mode 100644 index 0000000..d208968 --- /dev/null +++ b/modules/RichSiteSummary/RichSiteSummaryRepositoryExtension.cs @@ -0,0 +1,72 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Kruzya.TelegramBot.RichSiteSummary.Data; +using Microsoft.EntityFrameworkCore; +using Telegram.Bot.Types; + +namespace Kruzya.TelegramBot.RichSiteSummary +{ + public static class RichSiteSummaryRepositoryExtension + { + #region Subscriber + + /// + /// Searchs the , who reads a . + /// + /// + /// + /// The array with all subscribers. + public static async Task ByFeed(this DbSet repository, Feed feed) + { + return await repository.Where(subscriber => subscriber.Feed == feed) + .ToArrayAsync(); + } + + public static IQueryable AllSubscriptions(this DbSet repository, Chat chat) + { + return repository.Where(entity => entity.SubscriberId == chat.Id); + } + + public static async Task HasSubscription(this DbSet repository, Chat chat, Feed feed) + { + return (await repository.Where(subscriber => subscriber.Feed == feed && subscriber.SubscriberId == chat.Id) + .CountAsync()) != 0; + } + + #endregion + + #region Feed + + /// + /// Searchs the for fetching. + /// + /// + /// The period (in seconds) from last fetching. + /// Max results count + /// + public static async Task ForFetching(this DbSet repository, UInt16 period, UInt16 count) => + await repository.Where(feed => feed.UpdatedAt < (DateTime.Now - TimeSpan.FromSeconds(period))) + .OrderBy(feed => feed.UpdatedAt) + .Take(count) + .ToArrayAsync(); + + /// + /// Searchs the for fetching. + /// + /// + /// The period (in seconds) from last fetching. + /// + public static async Task ForFetching(this DbSet repository, UInt16 period) => + await repository.ForFetching(period, 25); + + #endregion + + #region Post + + public static async Task ByFeedAndUrl(this DbSet repository, string url, Feed feed = null) => + await repository.FirstOrDefaultAsync(post => post.Url == url && (feed == null || post.Feed == feed)); + + #endregion + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Service/MessageSender.cs b/modules/RichSiteSummary/Service/MessageSender.cs new file mode 100644 index 0000000..227a1ab --- /dev/null +++ b/modules/RichSiteSummary/Service/MessageSender.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Concurrent; +using System.Text; +using System.Threading.Tasks; +using BotFramework; +using Kruzya.TelegramBot.Core.Service; +using Microsoft.Extensions.Logging; +using Telegram.Bot.Exceptions; +using Telegram.Bot.Types; + +namespace Kruzya.TelegramBot.RichSiteSummary.Service +{ + public class MessageSender : AbstractTimedHostedService + { + protected override TimeSpan TimerPeriod => TimeSpan.FromSeconds(1); + + protected readonly ConcurrentQueue _queue; + protected readonly ConcurrentQueue _unsubscribeQueue; + + public MessageSender(ILogger logger, ITelegramBot bot, ConcurrentQueue userMessageQueue, ConcurrentQueue userUnsubscribeQueue) : base(logger, bot) + { + _unsubscribeQueue = userUnsubscribeQueue; + _queue = userMessageQueue; + } + + protected override async Task OnRun() + { + if (_queue.Count == 0) + { + return; + } + + UserMessage message; + if (!_queue.TryDequeue(out message)) + { + return; + } + + try + { + await _bot.BotClient.SendTextMessageAsync(message.ChatId, message.Text, message.ParseMode, + message.DisableWebPagePreview); + } + catch (ApiRequestException e) + { + if (!e.Message.Contains("bot was blocked by user")) + { + ReEnqueue(message, e, message.ChatId); // looks like a network issue + return; + } + + // User added bot to blacklist. + // Unsubscribe him. + _unsubscribeQueue.Enqueue(new UserUnsubscribe() + { + ChatId = message.ChatId + }); + } + catch (Exception e) + { + ReEnqueue(message, e); + } + } + + private void ReEnqueue(UserMessage message, Exception e = null, ChatId chatId = null) + { + if (e != null) + { + var eMessage = new StringBuilder(); + eMessage.Append(e.Message); + if (chatId != null) + { + eMessage.Append($" ({chatId.Identifier})"); + } + + _logger.LogError(eMessage.ToString()); + } + + _queue.Enqueue(message); + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Service/RssFetch.cs b/modules/RichSiteSummary/Service/RssFetch.cs new file mode 100644 index 0000000..c8299d6 --- /dev/null +++ b/modules/RichSiteSummary/Service/RssFetch.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CodeHollow.FeedReader; +using Kruzya.TelegramBot.Core.Extensions; +using Kruzya.TelegramBot.RichSiteSummary.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Feed = Kruzya.TelegramBot.RichSiteSummary.Data.Feed; + +namespace Kruzya.TelegramBot.RichSiteSummary.Service +{ + /// + /// Performs a RSS feed parsing. + /// Grabs the all feeds from database. + /// + /// TODO: move entity grabbing to another service. + /// + public class RssFetch : IHostedService, IDisposable + { + private Timer _timer; + + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ConcurrentQueue _queue; + + private TimeSpan timerPeriod => TimeSpan.FromSeconds(45); + + public RssFetch(ILogger logger, ConcurrentQueue queue, IServiceScopeFactory scopeFactory) + { + _scopeFactory = scopeFactory; + _logger = logger; + _queue = queue; + } + + #region IHostedService + /// + /// Initializes the RSS fetcher timer. + /// + /// + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("RSS fetcher service is starting."); + _timer = new Timer(DoFetch, null, TimeSpan.Zero, timerPeriod); + + return Task.CompletedTask; + } + + /// + /// Stops the RSS fetcher timer. + /// + /// + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("RSS fetcher service is stopping."); + _timer?.Change(Timeout.Infinite, 0); + + return Task.CompletedTask; + } + #endregion + #region IDisposable + /// + /// Disposes the timer. + /// + public void Dispose() + { + _timer?.Dispose(); + } + #endregion + + /// + /// Performs the job of fetching RSS data. + /// + /// + private async void DoFetch(object state) + { + _timer.Change(Timeout.Infinite, 0); + _logger.LogDebug("RSS fetcher service is triggered."); + + try + { + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var period = scope.ServiceProvider.GetRequiredService() + .GetValue("rssFetchPeriod"); + + var feeds = await dbContext.Feeds.ForFetching(period); + _logger.LogDebug("Received {count} feeds for fetching", new {count = feeds.Length}); + + foreach (var feed in feeds) + { + await ProcessFeed(feed, dbContext); + + feed.UpdatedAt = DateTime.Now; + dbContext.MarkAsModified(feed); + } + + await dbContext.SaveChangesAsync(); + } + finally + { + _timer.Change(timerPeriod, timerPeriod); + } + } + + #region Feeds + + private async Task ProcessFeed(Feed feed, RichSiteSummaryContext dbContext) + { + var parsedFeed = await FetchFeed(feed); + if (parsedFeed == null) + { + return; + } + + Post feedPost; + var newPosts = new List(); + foreach (var post in parsedFeed.Items) + { + feedPost = await dbContext.Posts.ByFeedAndUrl(post.Link, feed); + if (feedPost != null) + { + // skip. This post already exists. + continue; + } + + feedPost = dbContext.Posts.Create(); + feedPost.Feed = feed; + feedPost.Title = post.Title; + feedPost.Url = post.Link; + feedPost.PostedAt = post.PublishingDate.GetValueOrDefault(DateTime.Now); + + newPosts.Add(feedPost); + } + + if (newPosts.Count > 0) + { + var subscribers = await dbContext.Subscriptions.ByFeed(feed); + foreach (var post in newPosts) + { + var text = post.MessageText; + foreach (var subscriber in subscribers) + { + var message = new UserMessage() + { + ChatId = new ChatId(subscriber.SubscriberId), DisableWebPagePreview = true, + ParseMode = ParseMode.Html, Text = text + }; + + _queue.Enqueue(message); + } + } + } + } + + private async Task FetchFeed(Feed feed) + { + try + { + return await FeedReader.ReadAsync(feed.Url.ToString()); + } + catch (Exception e) + { + _logger.LogError($"Feed {feed} can't be fetched: {e.Message}"); + return null; + } + } + + #endregion + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Service/Unsubscriber.cs b/modules/RichSiteSummary/Service/Unsubscriber.cs new file mode 100644 index 0000000..fc4efce --- /dev/null +++ b/modules/RichSiteSummary/Service/Unsubscriber.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; +using BotFramework; +using Kruzya.TelegramBot.Core.Service; +using Kruzya.TelegramBot.RichSiteSummary.Data; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Telegram.Bot.Types; + +namespace Kruzya.TelegramBot.RichSiteSummary.Service +{ + public class Unsubscriber : AbstractTimedHostedService + { + protected readonly ConcurrentQueue _queue; + protected readonly IServiceScopeFactory _scopeFactory; + + protected override TimeSpan TimerPeriod => TimeSpan.FromSeconds(1); + + public Unsubscriber(ILogger logger, ITelegramBot bot, ConcurrentQueue queue, IServiceScopeFactory scopeFactory) : base(logger, bot) + { + _scopeFactory = scopeFactory; + _queue = queue; + } + + protected override async Task OnRun() + { + if (_queue.Count == 0) + { + return; + } + + UserUnsubscribe user; + if (!_queue.TryDequeue(out user)) + { + return; + } + + await UnsubscribeUser(user.ChatId); + } + + private async Task UnsubscribeUser(ChatId chatId) + { + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Subscriptions.RemoveRange(dbContext.Subscriptions.Where(s => s.SubscriberId == chatId.Identifier)); + await dbContext.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/Startup.cs b/modules/RichSiteSummary/Startup.cs new file mode 100644 index 0000000..a309818 --- /dev/null +++ b/modules/RichSiteSummary/Startup.cs @@ -0,0 +1,36 @@ +using System; +using BotFramework; +using Kruzya.TelegramBot.Core; +using Kruzya.TelegramBot.Core.Extensions; +using Kruzya.TelegramBot.RichSiteSummary.Data; +using Kruzya.TelegramBot.RichSiteSummary.Service; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Telegram.Bot.Types; + +namespace Kruzya.TelegramBot.RichSiteSummary +{ + public class Startup : Module + { + public Startup(Core.Core core) : base(core) + { + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(options => + options.UseLazyLoadingProxies() + .UseMySql(Configuration.GetConnectionString("RichSiteSummary"))); + + services.AddTelegramBotParameterParser>() + .AddTelegramBotParameterParser>() + .AddTelegramBotParameterParser>(); + + services.AddQueue() + .AddQueue(); + + services.AddHostedService(); + } + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/UserMessage.cs b/modules/RichSiteSummary/UserMessage.cs new file mode 100644 index 0000000..4bc93e2 --- /dev/null +++ b/modules/RichSiteSummary/UserMessage.cs @@ -0,0 +1,13 @@ +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace Kruzya.TelegramBot.RichSiteSummary +{ + public struct UserMessage + { + public ChatId ChatId; + public string Text; + public ParseMode ParseMode; + public bool DisableWebPagePreview; + } +} \ No newline at end of file diff --git a/modules/RichSiteSummary/UserUnsubscribe.cs b/modules/RichSiteSummary/UserUnsubscribe.cs new file mode 100644 index 0000000..b8b5ca1 --- /dev/null +++ b/modules/RichSiteSummary/UserUnsubscribe.cs @@ -0,0 +1,9 @@ +using Telegram.Bot.Types; + +namespace Kruzya.TelegramBot.RichSiteSummary +{ + public struct UserUnsubscribe + { + public ChatId ChatId; + } +} \ No newline at end of file