diff --git a/Directory.Build.props b/Directory.Build.props index f5c440c..cad26c8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.10.0 + 1.10.1 net10.0 preview enable diff --git a/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs index 809ab9d..57524e7 100644 --- a/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/ExportCalendar/ExportCalendarHandler.cs @@ -1,9 +1,11 @@ using System.Text; using Dapper; using GmRelay.Shared.Domain; +using Microsoft.Extensions.Configuration; using Npgsql; using Telegram.Bot; using Telegram.Bot.Types; +using Telegram.Bot.Types.ReplyMarkups; namespace GmRelay.Bot.Features.Sessions.ExportCalendar; @@ -11,20 +13,21 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu public sealed class ExportCalendarHandler( NpgsqlDataSource dataSource, - ITelegramBotClient botClient) + ITelegramBotClient botClient, + IConfiguration configuration) { public async Task HandleAsync(Message message, CancellationToken cancellationToken) { await using var connection = await dataSource.OpenConnectionAsync(cancellationToken); var sessions = await connection.QueryAsync( - @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt - FROM sessions s - JOIN game_groups g ON s.group_id = g.id - WHERE g.telegram_chat_id = @ChatId - AND s.status = @Planned - AND s.scheduled_at > NOW() - ORDER BY s.scheduled_at ASC", + @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt" + + " FROM sessions s" + + " JOIN game_groups g ON s.group_id = g.id" + + " WHERE g.telegram_chat_id = @ChatId" + + " AND s.status = @Planned" + + " AND s.scheduled_at > NOW()" + + " ORDER BY s.scheduled_at ASC", new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned }); var sessionsList = sessions.ToList(); @@ -54,8 +57,6 @@ public sealed class ExportCalendarHandler( sb.AppendLine($"DTSTART:{dtStart}"); sb.AppendLine($"DTEND:{dtEnd}"); sb.AppendLine($"SUMMARY:{s.Title}"); - // Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary - // In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text. sb.AppendLine("END:VEVENT"); } @@ -66,11 +67,45 @@ public sealed class ExportCalendarHandler( var inputFile = InputFile.FromStream(stream, "schedule.ics"); + // Create calendar subscription + string? subscriptionUrl = null; + var baseUrl = configuration["Web:BaseUrl"]; + var senderId = message.From?.Id; + if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue) + { + try + { + var token = Guid.NewGuid().ToString("N"); + var groupId = await connection.QueryFirstOrDefaultAsync( + @"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId", + new { ChatId = message.Chat.Id }); + + await connection.ExecuteAsync( + @"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at) + VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)", + new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup }); + + subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics"; + } + catch + { + // Non-critical: if subscription creation fails, still send the file + } + } + + var replyMarkup = subscriptionUrl is not null + ? new InlineKeyboardMarkup(new[] + { + new[] { InlineKeyboardButton.WithUrl("🔗 Подписаться на календарь", subscriptionUrl) } + }) + : null; + await botClient.SendDocument( chatId: message.Chat.Id, document: inputFile, caption: "📅 Ваш календарь игр!\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, + replyMarkup: replyMarkup, messageThreadId: message.MessageThreadId, cancellationToken: cancellationToken); } diff --git a/src/GmRelay.Bot/Migrations/V011__add_calendar_subscriptions.sql b/src/GmRelay.Bot/Migrations/V011__add_calendar_subscriptions.sql new file mode 100644 index 0000000..dbfe1f8 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V011__add_calendar_subscriptions.sql @@ -0,0 +1,11 @@ +CREATE TABLE calendar_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT UNIQUE NOT NULL, + user_telegram_id BIGINT NOT NULL, + group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE, + filter_type SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX ix_calendar_subscriptions_user_telegram_id ON calendar_subscriptions (user_telegram_id); diff --git a/src/GmRelay.Bot/appsettings.json b/src/GmRelay.Bot/appsettings.json index 276403a..432fd4d 100644 --- a/src/GmRelay.Bot/appsettings.json +++ b/src/GmRelay.Bot/appsettings.json @@ -9,5 +9,8 @@ "Telegram": { "BotToken": "", "MiniAppUrl": "" + }, + "Web": { + "BaseUrl": "" } } diff --git a/src/GmRelay.Shared/Domain/CalendarSubscriptionFilter.cs b/src/GmRelay.Shared/Domain/CalendarSubscriptionFilter.cs new file mode 100644 index 0000000..7b1b686 --- /dev/null +++ b/src/GmRelay.Shared/Domain/CalendarSubscriptionFilter.cs @@ -0,0 +1,7 @@ +namespace GmRelay.Shared.Domain; + +public enum CalendarSubscriptionFilter +{ + AllMyGroups = 0, + SpecificGroup = 1 +} diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 55902ab..5938b32 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -2,10 +2,10 @@ @@ -67,7 +67,7 @@ - Войти + Вход diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index a747dd6..81c45e4 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -23,6 +23,7 @@ builder.AddNpgsqlDataSource("gmrelaydb"); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add Bot Client builder.Services.AddSingleton(sp => @@ -145,6 +146,24 @@ app.MapPost("/auth/logout", async (HttpContext context) => return Results.Redirect("/"); }); +// Public calendar subscription endpoint (no auth required) +app.MapGet("/calendar/{token}.ics", async ( + string token, + CalendarSubscriptionService service, + CancellationToken ct) => +{ + try + { + var ics = await service.GetIcsAsync(token, ct); + var bytes = System.Text.Encoding.UTF8.GetBytes(ics); + return Results.File(bytes, "text/calendar", "schedule.ics"); + } + catch (SubscriptionNotFoundException) + { + return Results.NotFound(); + } +}); + app.Run(); static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name) diff --git a/src/GmRelay.Web/Services/CalendarSubscriptionService.cs b/src/GmRelay.Web/Services/CalendarSubscriptionService.cs new file mode 100644 index 0000000..a3f72af --- /dev/null +++ b/src/GmRelay.Web/Services/CalendarSubscriptionService.cs @@ -0,0 +1,93 @@ +using System.Text; +using Dapper; +using GmRelay.Shared.Domain; +using Npgsql; + +namespace GmRelay.Web.Services; + +public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource) +{ + private const string IcsProdId = "-//GM-Relay//TTRPG Schedule//EN"; + + public string GenerateToken() => Guid.NewGuid().ToString("N"); + + public async Task CreateSubscriptionAsync( + long userTelegramId, + Guid? groupId, + CalendarSubscriptionFilter filter, + CancellationToken ct = default) + { + var token = GenerateToken(); + await using var connection = await dataSource.OpenConnectionAsync(ct); + await connection.ExecuteAsync( + @"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at) + VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)", + new { token, userTelegramId, groupId, filterType = (int)filter }); + return token; + } + + public async Task GetIcsAsync(string token, CancellationToken ct = default) + { + await using var connection = await dataSource.OpenConnectionAsync(ct); + + var subscription = await connection.QueryFirstOrDefaultAsync( + @"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType + FROM calendar_subscriptions + WHERE token = @token + AND (expires_at IS NULL OR expires_at > now())", + new { token }); + + if (subscription is null) + throw new SubscriptionNotFoundException(); + + var sessions = await connection.QueryAsync( + subscription.FilterType == (int)CalendarSubscriptionFilter.SpecificGroup && subscription.GroupId.HasValue + ? @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt + FROM sessions s + WHERE s.group_id = @GroupId + AND s.status = @Planned + AND s.scheduled_at > NOW() + ORDER BY s.scheduled_at ASC" + : @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt + FROM sessions s + WHERE s.status = @Planned + AND s.scheduled_at > NOW() + ORDER BY s.scheduled_at ASC", + new { subscription.GroupId, Planned = SessionStatus.Planned }); + + var sb = new StringBuilder(); + sb.AppendLine("BEGIN:VCALENDAR"); + sb.AppendLine("VERSION:2.0"); + sb.AppendLine($"PRODID:{IcsProdId}"); + sb.AppendLine("CALSCALE:GREGORIAN"); + sb.AppendLine("METHOD:PUBLISH"); + + foreach (var s in sessions) + { + var dtStart = FormatIcsDate(s.ScheduledAt); + var dtEnd = FormatIcsDate(s.ScheduledAt.AddHours(4)); + sb.AppendLine("BEGIN:VEVENT"); + sb.AppendLine($"UID:{s.Id}@gmrelay"); + sb.AppendLine($"DTSTAMP:{FormatIcsDate(DateTime.UtcNow)}"); + sb.AppendLine($"DTSTART:{dtStart}"); + sb.AppendLine($"DTEND:{dtEnd}"); + sb.AppendLine($"SUMMARY:{EscapeIcsText(s.Title)}"); + sb.AppendLine("END:VEVENT"); + } + + sb.AppendLine("END:VCALENDAR"); + return sb.ToString(); + } + + private static string FormatIcsDate(DateTime dt) => dt.ToUniversalTime().ToString("yyyyMMddTHHmmssZ"); + + private static string EscapeIcsText(string text) => text + .Replace("\\", "\\\\") + .Replace(";", "\\;") + .Replace(",", "\\,") + .Replace("\n", "\\n") + .Replace("\r", ""); + + private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType); + private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt); +} diff --git a/src/GmRelay.Web/Services/SubscriptionNotFoundException.cs b/src/GmRelay.Web/Services/SubscriptionNotFoundException.cs new file mode 100644 index 0000000..cf573b0 --- /dev/null +++ b/src/GmRelay.Web/Services/SubscriptionNotFoundException.cs @@ -0,0 +1,6 @@ +namespace GmRelay.Web.Services; + +public sealed class SubscriptionNotFoundException : Exception +{ + public SubscriptionNotFoundException() : base("Calendar subscription not found.") { } +}