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": {