feat(#13): календарная подписка по URL #44
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.10.0</Version>
|
<Version>1.10.1</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
|
||||||
|
|
||||||
@@ -11,20 +13,21 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
|
|||||||
|
|
||||||
public sealed class ExportCalendarHandler(
|
public sealed class ExportCalendarHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient botClient)
|
ITelegramBotClient botClient,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
|
||||||
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
|
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
||||||
FROM sessions s
|
+ " FROM sessions s"
|
||||||
JOIN game_groups g ON s.group_id = g.id
|
+ " JOIN game_groups g ON s.group_id = g.id"
|
||||||
WHERE g.telegram_chat_id = @ChatId
|
+ " WHERE g.telegram_chat_id = @ChatId"
|
||||||
AND s.status = @Planned
|
+ " AND s.status = @Planned"
|
||||||
AND s.scheduled_at > NOW()
|
+ " AND s.scheduled_at > NOW()"
|
||||||
ORDER BY s.scheduled_at ASC",
|
+ " ORDER BY s.scheduled_at ASC",
|
||||||
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
|
||||||
|
|
||||||
var sessionsList = sessions.ToList();
|
var sessionsList = sessions.ToList();
|
||||||
@@ -54,8 +57,6 @@ public sealed class ExportCalendarHandler(
|
|||||||
sb.AppendLine($"DTSTART:{dtStart}");
|
sb.AppendLine($"DTSTART:{dtStart}");
|
||||||
sb.AppendLine($"DTEND:{dtEnd}");
|
sb.AppendLine($"DTEND:{dtEnd}");
|
||||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
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");
|
sb.AppendLine("END:VEVENT");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +67,45 @@ public sealed class ExportCalendarHandler(
|
|||||||
|
|
||||||
var inputFile = InputFile.FromStream(stream, "schedule.ics");
|
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<Guid?>(
|
||||||
|
@"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(
|
await botClient.SendDocument(
|
||||||
chatId: message.Chat.Id,
|
chatId: message.Chat.Id,
|
||||||
document: inputFile,
|
document: inputFile,
|
||||||
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
|
replyMarkup: replyMarkup,
|
||||||
messageThreadId: message.MessageThreadId,
|
messageThreadId: message.MessageThreadId,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -9,5 +9,8 @@
|
|||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": "",
|
"BotToken": "",
|
||||||
"MiniAppUrl": ""
|
"MiniAppUrl": ""
|
||||||
|
},
|
||||||
|
"Web": {
|
||||||
|
"BaseUrl": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
public enum CalendarSubscriptionFilter
|
||||||
|
{
|
||||||
|
AllMyGroups = 0,
|
||||||
|
SpecificGroup = 1
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
<a class="nav-brand" href="">
|
<a class="nav-brand" href="">
|
||||||
<span class="nav-brand-icon">🎲</span>
|
<span class="nav-brand-icon">🐢</span>
|
||||||
<span class="nav-brand-text">GM-Relay</span>
|
<span class="nav-brand-text">GM-Relay</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Навигационное меню">
|
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Переключить меню">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
</svg>
|
</svg>
|
||||||
Панель управления
|
Главная страница
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
|
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -52,11 +52,11 @@
|
|||||||
<polyline points="16 17 21 12 16 7"/>
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
Выйти
|
Выход
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v1.10.0</div>
|
<div class="nav-version">v1.10.1</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<polyline points="10 17 15 12 10 7"/>
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
Войти
|
Вход
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</NotAuthorized>
|
</NotAuthorized>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
|||||||
builder.Services.AddSingleton<TelegramAuthService>();
|
builder.Services.AddSingleton<TelegramAuthService>();
|
||||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||||
|
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||||
|
|
||||||
// Add Bot Client
|
// Add Bot Client
|
||||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||||
@@ -145,6 +146,24 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
|
|||||||
return Results.Redirect("/");
|
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();
|
app.Run();
|
||||||
|
|
||||||
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
|
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
|
||||||
|
|||||||
@@ -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<string> 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<string> GetIcsAsync(string token, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
|
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
|
||||||
|
@"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<CalendarSessionDto>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public sealed class SubscriptionNotFoundException : Exception
|
||||||
|
{
|
||||||
|
public SubscriptionNotFoundException() : base("Calendar subscription not found.") { }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user