🚧 RSS as a module

This commit is contained in:
2020-03-02 00:00:52 +04:00
parent e86049f4fd
commit eac0da5016
16 changed files with 1185 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
/// This file is a part of RSS Bot for Telegram.
/// License: MIT
/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) <kruzefag@gmail.com>
///
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace Kruzya.TelegramBot.RichSiteSummary.Data
{
/// <summary>
/// Represents a feed in database.
/// </summary>
public class Feed : RepresentableEntity
{
#region RepresentableEntity
protected override string ViewableText
{
get => Title;
}
protected override string ViewableUrl
{
get => HomePage;
}
#endregion
/// <summary>
/// The unique feed id.
/// </summary>
[Key]
public Guid FeedId { get; set; }
/// <summary>
/// The feed URL.
/// </summary>
[Required]
public string Url { get; set; }
/// <summary>
/// The home page for this feed.
/// </summary>
[Required]
public string HomePage { get; set; }
/// <summary>
/// Feed title.
/// </summary>
[MaxLength(256)]
[Required]
public string Title { get; set; }
/// <summary>
/// DateTime when feed fetched successfully in last time.
/// </summary>
[Required]
public DateTime UpdatedAt { get; set; }
/// <summary>
/// This flag indicates, bot should monitor changes in post date, or not.
/// </summary>
[Required]
[DefaultValue(false)]
public bool WatchPostDate { get; set; }
public Feed()
{
FeedId = new Guid();
UpdatedAt = DateTime.Now;
}
/// <summary>
/// All posts from this feed.
/// </summary>
public virtual ICollection<Post> Posts { get; set; }
/// <summary>
/// All exists subscriber for this feed.
/// </summary>
public virtual ICollection<Subscriber> Subscribers { get; set; }
}
}
+98
View File
@@ -0,0 +1,98 @@
/// This file is a part of RSS Bot for Telegram.
/// License: MIT
/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) <kruzefag@gmail.com>
///
using System;
using System.ComponentModel.DataAnnotations;
using System.Text;
using Telegram.Bot.Types.Enums;
namespace Kruzya.TelegramBot.RichSiteSummary.Data
{
/// <summary>
/// Represents a post from feed in database.
/// </summary>
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
/// <summary>
/// The unique post id.
/// </summary>
[Key]
public Guid PostId { get; set; }
/// <summary>
/// The feed unique identifier from where this post is fetched.
/// </summary>
public Guid FeedId { get; set; }
/// <summary>
/// The feed from where this post is fetched.
/// </summary>
[Required]
public virtual Feed Feed { get; set; }
/// <summary>
/// The post title.
/// </summary>
[MaxLength(256)]
[Required]
public string Title { get; set; }
/// <summary>
/// The URI where this post is located in web.
/// </summary>
[Required]
public string Url { get; set; }
/// <summary>
/// DateTime when post is created in RSS feed.
/// </summary>
[Required]
public DateTime PostedAt { get; set; }
/// <summary>
/// DateTime when post is fetched/updated in database.
/// </summary>
[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();
}
}
}
}
@@ -0,0 +1,65 @@
/// This file is a part of RSS Bot for Telegram.
/// License: MIT
/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) <kruzefag@gmail.com>
///
using System;
using System.Web;
using Telegram.Bot.Types.Enums;
namespace Kruzya.TelegramBot.RichSiteSummary.Data
{
/// <summary>
/// Implements the basic logic for rendering Database Entities in messages.
/// </summary>
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 $"<a href=\"{escapedUrl}\">{escapedText}</a>";
}
#endregion
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
namespace Kruzya.TelegramBot.RichSiteSummary.Data
{
public class RichSiteSummaryContext : DbContext
{
/// <summary>
/// Repository with all feeds in database.
/// </summary>
public DbSet<Feed> Feeds { get; set; }
/// <summary>
/// Repository with all posts in database.
/// </summary>
public DbSet<Post> Posts { get; set; }
/// <summary>
/// Repository with all subscriptions in database.
/// </summary>
public DbSet<Subscriber> Subscriptions { get; set; }
/// <summary>
/// Ensures the database is created and calls parent constructor.
/// </summary>
/// <param name="options"></param>
public RichSiteSummaryContext(DbContextOptions<RichSiteSummaryContext> options) : base(options)
{
Database.EnsureCreated();
}
/// <summary>
/// Setup the additional unique keys.
/// </summary>
/// <param name="modelBuilder"></param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Setup unique keys for Feed entity.
modelBuilder.Entity<Feed>().HasIndex(feed => feed.Url).IsUnique();
modelBuilder.Entity<Feed>().HasIndex(feed => feed.HomePage).IsUnique();
// Setup unique keys for Post entity.
modelBuilder.Entity<Post>()
.HasOne(p => p.Feed)
.WithMany(f => f.Posts)
.HasForeignKey(p => p.FeedId);
modelBuilder.Entity<Post>().HasIndex(post =>
new {post.FeedId, post.Url}).IsUnique();
// Setup foreign keys for Subscriber entity.
modelBuilder.Entity<Subscriber>()
.HasOne(s => s.Feed)
.WithMany(f => f.Subscribers)
.HasForeignKey(s => s.FeedId);
}
}
}
@@ -0,0 +1,56 @@
/// This file is a part of RSS Bot for Telegram.
/// License: MIT
/// Author: CrazyHackGUT aka Kruzya (Sergey Gut) <kruzefag@gmail.com>
///
using System;
using System.ComponentModel.DataAnnotations;
namespace Kruzya.TelegramBot.RichSiteSummary.Data
{
/// <summary>
/// Represents a subscription in database.
/// </summary>
public class Subscriber
{
/// <summary>
/// The unique Subscription identifier.
/// </summary>
[Key]
public Guid SubscriptionId { get; set; }
/// <summary>
/// The Telegram subscriber id.
/// </summary>
[Required]
public Int64 SubscriberId { get; set; }
/// <summary>
/// Unique feed identifier related with this Subscription. Identifies what user is read.
/// </summary>
public Guid FeedId { get; set; }
/// <summary>
/// Feed related with this Subscription. Identifies what user is read.
/// </summary>
[Required]
public virtual Feed Feed { get; set; }
/// <summary>
/// When user is subscribed on this feed.
/// </summary>
[Required]
public DateTime SubscribedAt { get; set; }
public Subscriber()
{
SubscriptionId = new Guid();
SubscribedAt = DateTime.Now;
}
public Subscriber(Feed feed) : base()
{
Feed = feed;
}
}
}
@@ -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;
}
}
}
+314
View File
@@ -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<Feed>(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($"<b>Posts at this moment</b>: {feed.Posts.Count}\n");
textBuilder.Append($"<b>RSS feed</b>: <code>{feed.Url}</code>\n");
textBuilder.Append($"<b>Home page</b>: <code>{feed.HomePage}</code>\n");
textBuilder.Append($"<b>Last synchronization</b>: <code>{feed.UpdatedAt}</code>");
var text = textBuilder.ToString();
var buttons = new List<InlineKeyboardButton>();
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 (<code>{query}</code>).", 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<bool> GenerateMenu() =>
await GenerateMenu(string.Empty);
protected async Task<bool> GenerateMenu(string query) =>
await GenerateMenu(query, 0);
protected async Task<bool> 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<InlineKeyboardButton>>();
List<InlineKeyboardButton> row;
foreach (var feed in result)
{
row = new List<InlineKeyboardButton>();
row.Add(new InlineKeyboardButton()
{
CallbackData = $"feed|{From.Id}|show|{feed.FeedId.ToString()}",
Text = feed.Title
});
buttons.Add(row);
}
if (backButton || nextButton)
{
row = new List<InlineKeyboardButton>();
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<Feed>(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<Feed>(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<Subscriber>(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
}
}
@@ -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 <b>{feedCount} feeds</b>\n");
textMessage.Append($"- saved <b>{postsCount} posts</b> for prevent re-sending\n");
textMessage.Append(
$"- exists <b>{subscriptionsCount} subscriptions on feeds</b> (<b>unique subscribers: {uniqueSubscribers}</b>)");
await Bot.SendTextMessageAsync(Chat, textMessage.ToString(), ParseMode.Html);
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<AssemblyName>TelegramBot.RichSiteSummary</AssemblyName>
<RootNamespace>Kruzya.TelegramBot.RichSiteSummary</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CodeHollow.FeedReader" Version="1.2.1" />
</ItemGroup>
</Project>
@@ -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
/// <summary>
/// Searchs the <see cref="Subscriber"/>, who reads a <see cref="Feed"/>.
/// </summary>
/// <param name="repository"></param>
/// <param name="feed"></param>
/// <returns>The array with all subscribers.</returns>
public static async Task<Subscriber[]> ByFeed(this DbSet<Subscriber> repository, Feed feed)
{
return await repository.Where(subscriber => subscriber.Feed == feed)
.ToArrayAsync();
}
public static IQueryable<Subscriber> AllSubscriptions(this DbSet<Subscriber> repository, Chat chat)
{
return repository.Where(entity => entity.SubscriberId == chat.Id);
}
public static async Task<bool> HasSubscription(this DbSet<Subscriber> repository, Chat chat, Feed feed)
{
return (await repository.Where(subscriber => subscriber.Feed == feed && subscriber.SubscriberId == chat.Id)
.CountAsync()) != 0;
}
#endregion
#region Feed
/// <summary>
/// Searchs the <see cref="Feed"/> for fetching.
/// </summary>
/// <param name="repository"></param>
/// <param name="period">The period (in seconds) from last fetching.</param>
/// <param name="count">Max results count</param>
/// <returns></returns>
public static async Task<Feed[]> ForFetching(this DbSet<Feed> repository, UInt16 period, UInt16 count) =>
await repository.Where(feed => feed.UpdatedAt < (DateTime.Now - TimeSpan.FromSeconds(period)))
.OrderBy(feed => feed.UpdatedAt)
.Take(count)
.ToArrayAsync();
/// <summary>
/// Searchs the <see cref="Feed"/> for fetching.
/// </summary>
/// <param name="repository"></param>
/// <param name="period">The period (in seconds) from last fetching.</param>
/// <returns></returns>
public static async Task<Feed[]> ForFetching(this DbSet<Feed> repository, UInt16 period) =>
await repository.ForFetching(period, 25);
#endregion
#region Post
public static async Task<Post> ByFeedAndUrl(this DbSet<Post> repository, string url, Feed feed = null) =>
await repository.FirstOrDefaultAsync(post => post.Url == url && (feed == null || post.Feed == feed));
#endregion
}
}
@@ -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<UserMessage> _queue;
protected readonly ConcurrentQueue<UserUnsubscribe> _unsubscribeQueue;
public MessageSender(ILogger<MessageSender> logger, ITelegramBot bot, ConcurrentQueue<UserMessage> userMessageQueue, ConcurrentQueue<UserUnsubscribe> 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);
}
}
}
+179
View File
@@ -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
{
/// <summary>
/// Performs a RSS feed parsing.
/// Grabs the all feeds from database.
///
/// TODO: move entity grabbing to another service.
/// </summary>
public class RssFetch : IHostedService, IDisposable
{
private Timer _timer;
private readonly ILogger _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ConcurrentQueue<UserMessage> _queue;
private TimeSpan timerPeriod => TimeSpan.FromSeconds(45);
public RssFetch(ILogger<RssFetch> logger, ConcurrentQueue<UserMessage> queue, IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
_logger = logger;
_queue = queue;
}
#region IHostedService
/// <summary>
/// Initializes the RSS fetcher timer.
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("RSS fetcher service is starting.");
_timer = new Timer(DoFetch, null, TimeSpan.Zero, timerPeriod);
return Task.CompletedTask;
}
/// <summary>
/// Stops the RSS fetcher timer.
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("RSS fetcher service is stopping.");
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
#endregion
#region IDisposable
/// <summary>
/// Disposes the timer.
/// </summary>
public void Dispose()
{
_timer?.Dispose();
}
#endregion
/// <summary>
/// Performs the job of fetching RSS data.
/// </summary>
/// <param name="state"></param>
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<RichSiteSummaryContext>();
var period = scope.ServiceProvider.GetRequiredService<IConfiguration>()
.GetValue<UInt16>("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<Post>();
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<CodeHollow.FeedReader.Feed> 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
}
}
@@ -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<UserUnsubscribe> _queue;
protected readonly IServiceScopeFactory _scopeFactory;
protected override TimeSpan TimerPeriod => TimeSpan.FromSeconds(1);
public Unsubscriber(ILogger<Unsubscriber> logger, ITelegramBot bot, ConcurrentQueue<UserUnsubscribe> 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<RichSiteSummaryContext>();
dbContext.Subscriptions.RemoveRange(dbContext.Subscriptions.Where(s => s.SubscriberId == chatId.Identifier));
await dbContext.SaveChangesAsync();
}
}
}
+36
View File
@@ -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<RichSiteSummaryContext>(options =>
options.UseLazyLoadingProxies()
.UseMySql(Configuration.GetConnectionString("RichSiteSummary")));
services.AddTelegramBotParameterParser<Feed, DbResolverParameter<Feed, RichSiteSummaryContext>>()
.AddTelegramBotParameterParser<Post, DbResolverParameter<Post, RichSiteSummaryContext>>()
.AddTelegramBotParameterParser<Subscriber, DbResolverParameter<Subscriber, RichSiteSummaryContext>>();
services.AddQueue<UserMessage>()
.AddQueue<UserUnsubscribe>();
services.AddHostedService<RssFetch>();
}
}
}
+13
View File
@@ -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;
}
}
@@ -0,0 +1,9 @@
using Telegram.Bot.Types;
namespace Kruzya.TelegramBot.RichSiteSummary
{
public struct UserUnsubscribe
{
public ChatId ChatId;
}
}