diff --git a/Directory.Packages.props b/Directory.Packages.props index 9cec753a..57fee1a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,5 +27,6 @@ + diff --git a/src/HonzaBotner.Discord.Services/Jobs/SuzJobProvider.cs b/src/HonzaBotner.Discord.Services/Jobs/SuzJobProvider.cs new file mode 100644 index 00000000..13a4b6d2 --- /dev/null +++ b/src/HonzaBotner.Discord.Services/Jobs/SuzJobProvider.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus; +using DSharpPlus.Entities; +using HonzaBotner.Discord.Services.Helpers; +using HonzaBotner.Discord.Services.Options; +using HonzaBotner.Scheduler.Contract; +using HonzaBotner.Services.Contract; +using HonzaBotner.Services.Contract.Dto; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace HonzaBotner.Discord.Services.Jobs; + +[Cron("0 30 10 * * 1-5")] +public class SuzJobProvider : IJob +{ + private readonly ILogger _logger; + + private readonly DiscordWrapper _discord; + private readonly ICanteenService _canteenService; + private readonly IGuildProvider _guildProvider; + private readonly CommonCommandOptions _commonOptions; + + public SuzJobProvider( + ILogger logger, + DiscordWrapper discord, + ICanteenService canteenService, + IGuildProvider guildProvider, + IOptions commonOptions) + { + _logger = logger; + _discord = discord; + _canteenService = canteenService; + _guildProvider = guildProvider; + _commonOptions = commonOptions.Value; + } + + public string Name => "suz-agata"; + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + IList canteens = await _canteenService.ListCanteensAsync(true, cancellationToken); + + DiscordGuild guild = await _guildProvider.GetCurrentGuildAsync(); + guild.ListActiveThreadsAsync() + _discord.Client.SendMessageAsync() + } +} diff --git a/src/HonzaBotner.Services.Contract/Dto/CanteenDishDto.cs b/src/HonzaBotner.Services.Contract/Dto/CanteenDishDto.cs new file mode 100644 index 00000000..cad66dd4 --- /dev/null +++ b/src/HonzaBotner.Services.Contract/Dto/CanteenDishDto.cs @@ -0,0 +1,9 @@ +namespace HonzaBotner.Services.Contract.Dto; + +public record CanteenDishDto( + string DishType, + string Name, + string Amount, + string StudentPrice, + string OtherPrice, + string PhotoLink = ""); diff --git a/src/HonzaBotner.Services.Contract/Dto/CanteenDto.cs b/src/HonzaBotner.Services.Contract/Dto/CanteenDto.cs new file mode 100644 index 00000000..09bb1054 --- /dev/null +++ b/src/HonzaBotner.Services.Contract/Dto/CanteenDto.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace HonzaBotner.Services.Contract.Dto; + +public record CanteenDto(int Id, string Name, bool Open, IReadOnlyList? TodayDishes = null); diff --git a/src/HonzaBotner.Services.Contract/ICanteenService.cs b/src/HonzaBotner.Services.Contract/ICanteenService.cs new file mode 100644 index 00000000..b51a27ee --- /dev/null +++ b/src/HonzaBotner.Services.Contract/ICanteenService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using HonzaBotner.Services.Contract.Dto; + +namespace HonzaBotner.Services.Contract; + +public interface ICanteenService +{ + Task> ListCanteensAsync(bool onlyOpen = false, CancellationToken cancellationToken = default); + Task GetCurrentMenuAsync(CanteenDto canteen, CancellationToken cancellationToken = default); +} diff --git a/src/HonzaBotner.Services.Test/SuzCanteenServiceTest.cs b/src/HonzaBotner.Services.Test/SuzCanteenServiceTest.cs new file mode 100644 index 00000000..766a4874 --- /dev/null +++ b/src/HonzaBotner.Services.Test/SuzCanteenServiceTest.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; +using System.Linq; +using System.Net.Http; +using HonzaBotner.Services.Contract; +using HonzaBotner.Services.Contract.Dto; +using Xunit; + +namespace HonzaBotner.Services.Test; + +public class SuzCanteenServiceTest +{ + private static readonly CanteenDto Technicka = new(3, "Technická menza", true); + + [Fact] + public async void ListCanteens() + { + ICanteenService canteenService = new SuzCanteenService(new HttpClient()); // TODO: Init + + var canteens = (await canteenService.ListCanteensAsync()).ToList(); + + Assert.NotEmpty(canteens); + Assert.All(canteens, dto => + { + Assert.NotEmpty(dto.Name); + Assert.NotEqual(0, dto.Id); + }); + + Assert.Contains(Technicka, canteens); + } + + [Fact] + public async void GetCurrentMenu() + { + ICanteenService canteenService = new SuzCanteenService(new HttpClient()); // TODO: Init + var canteens = await canteenService.ListCanteensAsync(true); + + foreach (CanteenDto canteen in canteens) + { + var canteenWithMenu = await canteenService.GetCurrentMenuAsync(canteen); + // Method modifies only today dishes + Assert.Equal(canteen, canteenWithMenu with { TodayDishes = canteen.TodayDishes }); + Assert.NotNull(canteenWithMenu); + + foreach (var dish in canteenWithMenu.TodayDishes!) + { + Assert.NotEmpty(dish.DishType); + Assert.NotEqual("Jiné", dish.DishType); + Assert.NotEmpty(dish.Name); + Assert.NotEmpty(dish.StudentPrice); + Assert.NotEmpty(dish.OtherPrice); + } + } + } +} diff --git a/src/HonzaBotner.Services/HonzaBotner.Services.csproj b/src/HonzaBotner.Services/HonzaBotner.Services.csproj index fbc867a8..98965744 100644 --- a/src/HonzaBotner.Services/HonzaBotner.Services.csproj +++ b/src/HonzaBotner.Services/HonzaBotner.Services.csproj @@ -6,6 +6,7 @@ + diff --git a/src/HonzaBotner.Services/SuzCanteenService.cs b/src/HonzaBotner.Services/SuzCanteenService.cs new file mode 100644 index 00000000..9d1ba0e9 --- /dev/null +++ b/src/HonzaBotner.Services/SuzCanteenService.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using HonzaBotner.Services.Contract; +using HonzaBotner.Services.Contract.Dto; +using HtmlAgilityPack; +using Microsoft.Extensions.Caching.Memory; + +namespace HonzaBotner.Services; + +public class SuzCanteenService: ICanteenService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _memoryCache; + + public SuzCanteenService(HttpClient httpClient, IMemoryCache memoryCache) + { + _httpClient = httpClient; + _memoryCache = memoryCache; + } + + public async Task> ListCanteensAsync(bool onlyOpen = false, CancellationToken cancellationToken = default) + { + const string cacheKey = $"{nameof(SuzCanteenService)}_{nameof(ListCanteensAsync)}"; + + if (!_memoryCache.TryGetValue(cacheKey, out List canteens)) + { + + const string url = "https://agata.suz.cvut.cz/jidelnicky/index.php"; + string pageContent = await _httpClient.GetStringAsync(url, cancellationToken); + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(pageContent); + + canteens = htmlDoc.DocumentNode.SelectNodes("//ul[@id='menzy']/li/a") + .Select(node => + { + int.TryParse(node.Id.Replace("podSh", ""), out int id); + bool open = node.SelectSingleNode($"{node.XPath}/img") + .GetAttributeValue("src", "closed").Contains("Otevreno"); + string name = node.InnerText.Trim(); + return new CanteenDto(id, name, open); + }).ToList(); + + var cacheEntryOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(TimeSpan.FromHours(1)); + + _memoryCache.Set(cacheKey, canteens, cacheEntryOptions); + } + + if (onlyOpen) + return canteens.Where(c => c.Open).ToList(); + return canteens.ToList(); + } + + public async Task GetCurrentMenuAsync(CanteenDto canteen, CancellationToken cancellationToken = default) + { + const string url = "https://agata.suz.cvut.cz/jidelnicky/index.php?clPodsystem={0}"; + string pageContent = await _httpClient.GetStringAsync(string.Format(url, canteen.Id), cancellationToken); + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(pageContent); + + var rows = htmlDoc.DocumentNode + .SelectNodes("//div[@class='data']/table[@class='table table-condensed']/tbody/tr"); + if (rows is null) + { + // TODO: Log and maybe say something to user? + return canteen with { TodayDishes = ArraySegment.Empty}; + } + + var dishes = new List(); + + string currentDishType = "Jiné"; + + foreach (var row in rows) + { + if (row is null) continue; + + var th = row.ChildNodes["th"]; + if (th is not null) + { + currentDishType = th.InnerText.Trim(); + continue; + } + + var tds = row.SelectNodes("td"); + if (tds is null) continue; + + string amount = TrimHtml(tds[1].InnerText); + string name = TrimHtml(tds[2].InnerText); + string photo = ParsePhoto(tds[4]); + + string studentPrice = TrimHtml(tds[5].InnerText); + string otherPrice = TrimHtml(tds[6].InnerText); + + dishes.Add(new CanteenDishDto(currentDishType, name, amount, studentPrice, otherPrice, photo)); + } + + + return canteen with { TodayDishes = dishes }; + } + + + private static string ParsePhoto(HtmlNode node) + { + var photo = node.Descendants("a")?.FirstOrDefault(); + + if (photo is null) + { + return string.Empty; + } + + string link = photo.GetAttributeValue("href", string.Empty); + + if (string.IsNullOrEmpty(link)) return link; + + return $"https://agata.suz.cvut.cz/jidelnicky/{link}"; + } + + private static string TrimHtml(string text) + { + return text.Replace(" ", " ").Trim(); + } +} diff --git a/src/HonzaBotner/appsettings.CvutFit.json b/src/HonzaBotner/appsettings.CvutFit.json index d3378490..f2fe52e4 100644 --- a/src/HonzaBotner/appsettings.CvutFit.json +++ b/src/HonzaBotner/appsettings.CvutFit.json @@ -56,6 +56,9 @@ "issueTrackerUrl": "https://github.com/fit-ctu-discord/honza-botner/issues", "changelogUrl": "https://github.com/fit-ctu-discord/honza-botner/releases" }, + "SuzCanteenOptions": { + "PublishThre" + }, "DiscordRoles": { "AuthenticatedRoleIds": [681559148546359432, 686867633085480970], "AuthRoleMapping": {