Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb8cbb7a40 | |||
| 93e7c1ac66 | |||
| 4d6651827b | |||
| 9e7a202f42 | |||
| 1c4cfb71c0 | |||
| ecc2236937 | |||
| 3002db6534 | |||
| 176f1105ab | |||
| b6af5f047c | |||
| 66e7f5eea7 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.0.0
|
VERSION: 1.1.3
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.1.3</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.0.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.3
|
||||||
container_name: gmrelay_bot
|
container_name: gmrelay_bot
|
||||||
restart: always
|
restart: always
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
|
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.0.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.3
|
||||||
container_name: gmrelay_web
|
container_name: gmrelay_web
|
||||||
restart: always
|
restart: always
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
|
||||||
// ── Command ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public sealed record HandleRsvpCommand(
|
public sealed record HandleRsvpCommand(
|
||||||
Guid SessionId,
|
Guid SessionId,
|
||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
@@ -17,13 +14,12 @@ public sealed record HandleRsvpCommand(
|
|||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
||||||
|
|
||||||
internal sealed record SessionContext(
|
internal sealed record SessionContext(
|
||||||
string Title,
|
string Title,
|
||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
long GmTelegramId,
|
long GmTelegramId,
|
||||||
long TelegramChatId);
|
long TelegramChatId);
|
||||||
|
|
||||||
@@ -33,21 +29,6 @@ internal sealed record ParticipantRsvp(
|
|||||||
string? TelegramUsername,
|
string? TelegramUsername,
|
||||||
string RsvpStatus);
|
string RsvpStatus);
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles the "Буду" / "Не смогу" callback query.
|
|
||||||
///
|
|
||||||
/// Flow:
|
|
||||||
/// 1. Validate that the user is a participant in this session
|
|
||||||
/// 2. Record or update their RSVP (idempotent)
|
|
||||||
/// 3. If declined → alert GM privately, revert session if was Confirmed
|
|
||||||
/// 4. If all non-GM players confirmed → mark session Confirmed, notify group + GM
|
|
||||||
/// 5. Update the inline keyboard to show current RSVP status
|
|
||||||
///
|
|
||||||
/// Concurrency: two simultaneous clicks on different rows don't conflict (MVCC).
|
|
||||||
/// The last EditMessage wins, which is fine — both reflect up-to-date state.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HandleRsvpHandler(
|
public sealed class HandleRsvpHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
@@ -58,12 +39,11 @@ public sealed class HandleRsvpHandler(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
// ── 1. Validate participant ──────────────────────────────────
|
|
||||||
|
|
||||||
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
||||||
"""
|
"""
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM session_participants sp
|
SELECT 1
|
||||||
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId
|
WHERE sp.session_id = @SessionId
|
||||||
AND p.telegram_id = @TelegramUserId
|
AND p.telegram_id = @TelegramUserId
|
||||||
@@ -82,8 +62,6 @@ public sealed class HandleRsvpHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Record RSVP (idempotent) ─────────────────────────────
|
|
||||||
|
|
||||||
var updated = await connection.ExecuteAsync(
|
var updated = await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE session_participants
|
UPDATE session_participants
|
||||||
@@ -98,7 +76,6 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
if (updated == 0)
|
if (updated == 0)
|
||||||
{
|
{
|
||||||
// Already in this state — just dismiss the loading spinner
|
|
||||||
var alreadyText = command.Status == RsvpStatus.Confirmed
|
var alreadyText = command.Status == RsvpStatus.Confirmed
|
||||||
? "Вы уже подтвердили участие."
|
? "Вы уже подтвердили участие."
|
||||||
: "Вы уже отказались от участия.";
|
: "Вы уже отказались от участия.";
|
||||||
@@ -110,11 +87,11 @@ public sealed class HandleRsvpHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Load session context ─────────────────────────────────
|
|
||||||
|
|
||||||
var session = await connection.QuerySingleAsync<SessionContext>(
|
var session = await connection.QuerySingleAsync<SessionContext>(
|
||||||
"""
|
"""
|
||||||
SELECT s.title, s.scheduled_at AS ScheduledAt,
|
SELECT s.title,
|
||||||
|
s.scheduled_at AS ScheduledAt,
|
||||||
|
s.status AS Status,
|
||||||
g.gm_telegram_id AS GmTelegramId,
|
g.gm_telegram_id AS GmTelegramId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -124,26 +101,27 @@ public sealed class HandleRsvpHandler(
|
|||||||
new { command.SessionId },
|
new { command.SessionId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// ── 4. Handle decline ───────────────────────────────────────
|
|
||||||
|
|
||||||
if (command.Status == RsvpStatus.Declined)
|
if (command.Status == RsvpStatus.Declined)
|
||||||
{
|
{
|
||||||
// Revert session to ConfirmationSent if it was Confirmed
|
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, totalParticipants: 0, confirmedParticipants: 0);
|
||||||
await connection.ExecuteAsync(
|
|
||||||
"""
|
if (decision.ShouldRevertSessionToConfirmationSent)
|
||||||
UPDATE sessions
|
{
|
||||||
SET status = @ConfirmationSent, updated_at = now()
|
await connection.ExecuteAsync(
|
||||||
WHERE id = @SessionId AND status = @Confirmed
|
"""
|
||||||
""",
|
UPDATE sessions
|
||||||
new
|
SET status = @ConfirmationSent, updated_at = now()
|
||||||
{
|
WHERE id = @SessionId AND status = @Confirmed
|
||||||
command.SessionId,
|
""",
|
||||||
ConfirmationSent = SessionStatus.ConfirmationSent,
|
new
|
||||||
Confirmed = SessionStatus.Confirmed
|
{
|
||||||
},
|
command.SessionId,
|
||||||
transaction);
|
ConfirmationSent = SessionStatus.ConfirmationSent,
|
||||||
|
Confirmed = SessionStatus.Confirmed
|
||||||
|
},
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
// Alert GM immediately via private message
|
|
||||||
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
var declinedPlayer = await connection.QuerySingleAsync<string>(
|
||||||
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||||
new { command.TelegramUserId },
|
new { command.TelegramUserId },
|
||||||
@@ -151,7 +129,6 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// Send alert outside transaction (network call)
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
@@ -161,24 +138,22 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}",
|
logger.LogWarning(ex, "Failed to send decline alert to GM for session {SessionId}", command.SessionId);
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(
|
await bot.AnswerCallbackQuery(
|
||||||
callbackQueryId: command.CallbackQueryId,
|
callbackQueryId: command.CallbackQueryId,
|
||||||
text: "Вы отказались от участия.",
|
text: decision.CallbackText,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
// ── 5. Handle confirm — check if ALL confirmed ──────────────
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
count(*) AS Total,
|
count(*) AS Total,
|
||||||
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
||||||
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
||||||
FROM session_participants
|
FROM session_participants
|
||||||
WHERE session_id = @SessionId AND is_gm = false
|
WHERE session_id = @SessionId AND is_gm = false
|
||||||
""",
|
""",
|
||||||
@@ -190,9 +165,9 @@ public sealed class HandleRsvpHandler(
|
|||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
var allConfirmed = counts.Confirmed == counts.Total;
|
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
|
||||||
|
|
||||||
if (allConfirmed)
|
if (decision.ShouldMarkSessionConfirmed)
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
@@ -206,9 +181,8 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
if (allConfirmed)
|
if (decision.ShouldNotifyGroup)
|
||||||
{
|
{
|
||||||
// Notify group
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
@@ -218,11 +192,12 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}",
|
logger.LogWarning(ex, "Failed to send group confirmation for session {SessionId}", command.SessionId);
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify GM privately
|
if (decision.ShouldNotifyGm)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
@@ -232,27 +207,20 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}",
|
logger.LogWarning(ex, "Failed to send GM confirmation for session {SessionId}", command.SessionId);
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(
|
await bot.AnswerCallbackQuery(
|
||||||
callbackQueryId: command.CallbackQueryId,
|
callbackQueryId: command.CallbackQueryId,
|
||||||
text: "Вы подтвердили участие!",
|
text: decision.CallbackText,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Update inline keyboard message ───────────────────────
|
|
||||||
|
|
||||||
await UpdateConfirmationMessage(command, session, ct);
|
await UpdateConfirmationMessage(command, session, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task UpdateConfirmationMessage(HandleRsvpCommand command, SessionContext session, CancellationToken ct)
|
||||||
/// Re-renders the confirmation message with current RSVP statuses.
|
|
||||||
/// </summary>
|
|
||||||
private async Task UpdateConfirmationMessage(
|
|
||||||
HandleRsvpCommand command, SessionContext session, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -260,10 +228,10 @@ public sealed class HandleRsvpHandler(
|
|||||||
|
|
||||||
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
var participants = (await connection.QueryAsync<ParticipantRsvp>(
|
||||||
"""
|
"""
|
||||||
SELECT p.telegram_id AS TelegramId,
|
SELECT p.telegram_id AS TelegramId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.telegram_username AS TelegramUsername,
|
||||||
sp.rsvp_status AS RsvpStatus
|
sp.rsvp_status AS RsvpStatus
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON p.id = sp.player_id
|
JOIN players p ON p.id = sp.player_id
|
||||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
||||||
@@ -279,34 +247,47 @@ public sealed class HandleRsvpHandler(
|
|||||||
{
|
{
|
||||||
$"🎲 Подтвердите участие в «{session.Title}»",
|
$"🎲 Подтвердите участие в «{session.Title}»",
|
||||||
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
$"📅 {session.ScheduledAt.FormatMoscow()} (МСК)",
|
||||||
""
|
string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var p in confirmed)
|
foreach (var participant in confirmed)
|
||||||
lines.Add($" ✅ {FormatName(p)}");
|
{
|
||||||
foreach (var p in declined)
|
lines.Add($" ✅ {FormatName(participant)}");
|
||||||
lines.Add($" ❌ ~~{FormatName(p)}~~");
|
}
|
||||||
foreach (var p in pending)
|
|
||||||
lines.Add($" ⏳ {FormatName(p)}");
|
|
||||||
|
|
||||||
lines.Add("");
|
foreach (var participant in declined)
|
||||||
|
{
|
||||||
|
lines.Add($" ❌ ~~{FormatName(participant)}~~");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var participant in pending)
|
||||||
|
{
|
||||||
|
lines.Add($" ⏳ {FormatName(participant)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add(string.Empty);
|
||||||
|
|
||||||
if (confirmed.Count == participants.Count)
|
if (confirmed.Count == participants.Count)
|
||||||
|
{
|
||||||
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
|
lines.Add($"Статус: ✅ все подтвердили ({confirmed.Count}/{participants.Count})");
|
||||||
|
}
|
||||||
else if (declined.Count > 0)
|
else if (declined.Count > 0)
|
||||||
|
{
|
||||||
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
|
lines.Add($"Статус: ⚠️ есть отказы ({confirmed.Count}/{participants.Count} подтвердили)");
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
|
lines.Add($"Статус: ожидаем подтверждения ({confirmed.Count}/{participants.Count})");
|
||||||
|
}
|
||||||
|
|
||||||
var text = string.Join("\n", lines);
|
var text = string.Join("\n", lines);
|
||||||
|
|
||||||
// Keep buttons unless everyone confirmed
|
|
||||||
var replyMarkup = confirmed.Count == participants.Count
|
var replyMarkup = confirmed.Count == participants.Count
|
||||||
? null
|
? null
|
||||||
: new InlineKeyboardMarkup([
|
: new InlineKeyboardMarkup([
|
||||||
[
|
[
|
||||||
InlineKeyboardButton.WithCallbackData("\u2705 Буду", $"rsvp:confirm:{command.SessionId}"),
|
InlineKeyboardButton.WithCallbackData("✅ Буду", $"rsvp:confirm:{command.SessionId}"),
|
||||||
InlineKeyboardButton.WithCallbackData("\u274c Не смогу", $"rsvp:decline:{command.SessionId}")
|
InlineKeyboardButton.WithCallbackData("❌ Не смогу", $"rsvp:decline:{command.SessionId}")
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -319,12 +300,10 @@ public sealed class HandleRsvpHandler(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// EditMessage can fail if message is too old or unchanged — non-critical
|
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
|
||||||
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}",
|
|
||||||
command.SessionId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatName(ParticipantRsvp p) =>
|
private static string FormatName(ParticipantRsvp participant) =>
|
||||||
p.TelegramUsername is not null ? $"@{p.TelegramUsername}" : p.DisplayName;
|
participant.TelegramUsername is not null ? $"@{participant.TelegramUsername}" : participant.DisplayName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
|
||||||
|
internal sealed record RsvpFlowDecision(
|
||||||
|
string CallbackText,
|
||||||
|
bool ShouldAlertGm,
|
||||||
|
bool ShouldRevertSessionToConfirmationSent,
|
||||||
|
bool ShouldMarkSessionConfirmed,
|
||||||
|
bool ShouldNotifyGroup,
|
||||||
|
bool ShouldNotifyGm);
|
||||||
|
|
||||||
|
internal static class RsvpFlowRules
|
||||||
|
{
|
||||||
|
public static RsvpFlowDecision Evaluate(
|
||||||
|
string requestedStatus,
|
||||||
|
string currentSessionStatus,
|
||||||
|
int totalParticipants,
|
||||||
|
int confirmedParticipants)
|
||||||
|
{
|
||||||
|
if (requestedStatus == RsvpStatus.Declined)
|
||||||
|
{
|
||||||
|
return new RsvpFlowDecision(
|
||||||
|
CallbackText: "\u0412\u044b \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043e\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u044f.",
|
||||||
|
ShouldAlertGm: true,
|
||||||
|
ShouldRevertSessionToConfirmationSent: currentSessionStatus == SessionStatus.Confirmed,
|
||||||
|
ShouldMarkSessionConfirmed: false,
|
||||||
|
ShouldNotifyGroup: false,
|
||||||
|
ShouldNotifyGm: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var everyoneConfirmed = confirmedParticipants == totalParticipants;
|
||||||
|
|
||||||
|
return new RsvpFlowDecision(
|
||||||
|
CallbackText: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u0443\u0447\u0430\u0441\u0442\u0438\u0435!",
|
||||||
|
ShouldAlertGm: false,
|
||||||
|
ShouldRevertSessionToConfirmationSent: false,
|
||||||
|
ShouldMarkSessionConfirmed: everyoneConfirmed,
|
||||||
|
ShouldNotifyGroup: everyoneConfirmed,
|
||||||
|
ShouldNotifyGm: everyoneConfirmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
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.CreateSession;
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
@@ -16,37 +13,25 @@ public sealed class CreateSessionHandler(
|
|||||||
{
|
{
|
||||||
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var text = message.Text ?? "";
|
var parseResult = NewSessionCommandParser.Parse(message.Text, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
string? title = null;
|
foreach (var timeInput in parseResult.PastTimeInputs)
|
||||||
string? link = null;
|
|
||||||
var scheduledTimes = new List<DateTimeOffset>();
|
|
||||||
|
|
||||||
foreach (var line in text.Split('\n'))
|
|
||||||
{
|
{
|
||||||
var trimmed = line.Trim();
|
await botClient.SendMessage(
|
||||||
if (trimmed.StartsWith("Название:", StringComparison.OrdinalIgnoreCase))
|
message.Chat.Id,
|
||||||
title = trimmed["Название:".Length..].Trim();
|
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
|
||||||
else if (trimmed.StartsWith("Ссылка:", StringComparison.OrdinalIgnoreCase))
|
cancellationToken: cancellationToken);
|
||||||
link = trimmed["Ссылка:".Length..].Trim();
|
|
||||||
else if (trimmed.StartsWith("Время:", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var timeStr = trimmed["Время:".Length..].Trim();
|
|
||||||
if (MoscowTime.TryParseMoscow(timeStr, out var scheduledAt))
|
|
||||||
{
|
|
||||||
if (scheduledAt > DateTimeOffset.UtcNow)
|
|
||||||
scheduledTimes.Add(scheduledAt);
|
|
||||||
else
|
|
||||||
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Дата {timeStr} находится в прошлом и будет пропущена.", cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await botClient.SendMessage(message.Chat.Id, $"⚠️ Предупреждение: Некорректный формат времени '{timeStr}'. Пропущено.", cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(link) || scheduledTimes.Count == 0)
|
foreach (var timeInput in parseResult.InvalidTimeInputs)
|
||||||
|
{
|
||||||
|
await botClient.SendMessage(
|
||||||
|
message.Chat.Id,
|
||||||
|
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parseResult.IsValid)
|
||||||
{
|
{
|
||||||
await botClient.SendMessage(
|
await botClient.SendMessage(
|
||||||
chatId: message.Chat.Id,
|
chatId: message.Chat.Id,
|
||||||
@@ -55,8 +40,10 @@ public sealed class CreateSessionHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var title = parseResult.Title!;
|
||||||
|
var link = parseResult.Link!;
|
||||||
var gmId = message.From!.Id;
|
var gmId = message.From!.Id;
|
||||||
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}");
|
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
|
||||||
var gmUsername = message.From.Username;
|
var gmUsername = message.From.Username;
|
||||||
|
|
||||||
var chatId = message.Chat.Id;
|
var chatId = message.Chat.Id;
|
||||||
@@ -67,20 +54,24 @@ public sealed class CreateSessionHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 1. Убеждаемся, что GM зарегистрирован
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
@"INSERT INTO players (telegram_id, display_name, telegram_username)
|
"""
|
||||||
VALUES (@TgId, @Name, @Username)
|
INSERT INTO players (telegram_id, display_name, telegram_username)
|
||||||
ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username;",
|
VALUES (@TgId, @Name, @Username)
|
||||||
|
ON CONFLICT (telegram_id) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
telegram_username = EXCLUDED.telegram_username;
|
||||||
|
""",
|
||||||
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
new { TgId = gmId, Name = gmName, Username = gmUsername },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// 2. Убеждаемся, что Группа зарегистрирована
|
|
||||||
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
var groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
@"INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
"""
|
||||||
VALUES (@ChatId, @ChatName, @GmId)
|
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id)
|
||||||
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
VALUES (@ChatId, @ChatName, @GmId)
|
||||||
RETURNING id;",
|
ON CONFLICT (telegram_chat_id) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id;
|
||||||
|
""",
|
||||||
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
@@ -94,29 +85,36 @@ public sealed class CreateSessionHandler(
|
|||||||
messageThreadId = topic.MessageThreadId;
|
messageThreadId = topic.MessageThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Создаем сессии в цикле с общим batch_id
|
|
||||||
var batchId = Guid.NewGuid();
|
var batchId = Guid.NewGuid();
|
||||||
var sessions = new List<SessionBatchDto>();
|
var sessions = new List<SessionBatchDto>();
|
||||||
|
|
||||||
foreach (var dt in scheduledTimes.OrderBy(d => d))
|
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
|
||||||
{
|
{
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
@"INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
|
"""
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
|
||||||
RETURNING id;",
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId)
|
||||||
new { BatchId = batchId, GroupId = groupId, Title = title, Link = link, ScheduledAt = dt, ThreadId = messageThreadId },
|
RETURNING id;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
BatchId = batchId,
|
||||||
|
GroupId = groupId,
|
||||||
|
Title = title,
|
||||||
|
Link = link,
|
||||||
|
ScheduledAt = scheduledAt,
|
||||||
|
ThreadId = messageThreadId
|
||||||
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
sessions.Add(new SessionBatchDto(sessionId, dt.UtcDateTime, "Planned"));
|
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "Planned"));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
|
||||||
|
|
||||||
// 4. Отправляем сообщение в чат
|
|
||||||
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
var renderResult = SessionBatchRenderer.Render(title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||||
|
|
||||||
|
|
||||||
var batchMessage = await botClient.SendMessage(
|
var batchMessage = await botClient.SendMessage(
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
messageThreadId: messageThreadId,
|
messageThreadId: messageThreadId,
|
||||||
@@ -125,12 +123,10 @@ public sealed class CreateSessionHandler(
|
|||||||
replyMarkup: renderResult.Markup,
|
replyMarkup: renderResult.Markup,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
// 4b. Сохраняем message_id батч-сообщения для дальнейшего редактирования
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
|
||||||
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
new { MsgId = batchMessage.MessageId, BatchId = batchId });
|
||||||
|
|
||||||
// 5. Удаляем исходное сообщение с командой /newsession, чтобы не спамить
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await botClient.DeleteMessage(
|
await botClient.DeleteMessage(
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
internal sealed record NewSessionParseResult(
|
||||||
|
string? Title,
|
||||||
|
string? Link,
|
||||||
|
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||||
|
IReadOnlyList<string> PastTimeInputs,
|
||||||
|
IReadOnlyList<string> InvalidTimeInputs)
|
||||||
|
{
|
||||||
|
public bool IsValid =>
|
||||||
|
!string.IsNullOrWhiteSpace(Title) &&
|
||||||
|
!string.IsNullOrWhiteSpace(Link) &&
|
||||||
|
ScheduledTimes.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class NewSessionCommandParser
|
||||||
|
{
|
||||||
|
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
|
||||||
|
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
|
||||||
|
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
|
||||||
|
|
||||||
|
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
string? title = null;
|
||||||
|
string? link = null;
|
||||||
|
var scheduledTimes = new List<DateTimeOffset>();
|
||||||
|
var pastTimeInputs = new List<string>();
|
||||||
|
var invalidTimeInputs = new List<string>();
|
||||||
|
|
||||||
|
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
title = line[TitlePrefix.Length..].Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
link = line[LinkPrefix.Length..].Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeInput = line[TimePrefix.Length..].Trim();
|
||||||
|
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
|
||||||
|
{
|
||||||
|
invalidTimeInputs.Add(timeInput);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduledAt <= nowUtc)
|
||||||
|
{
|
||||||
|
pastTimeInputs.Add(timeInput);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledTimes.Add(scheduledAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NewSessionParseResult(title, link, scheduledTimes, pastTimeInputs, invalidTimeInputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-88
@@ -1,25 +1,20 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Telegram.Bot.Types.ReplyMarkups;
|
using Telegram.Bot.Types.ReplyMarkups;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
// ── Command ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public sealed record HandleRescheduleVoteCommand(
|
public sealed record HandleRescheduleVoteCommand(
|
||||||
Guid ProposalId,
|
Guid ProposalId,
|
||||||
string Vote, // "yes" or "no"
|
string Vote,
|
||||||
long TelegramUserId,
|
long TelegramUserId,
|
||||||
string CallbackQueryId,
|
string CallbackQueryId,
|
||||||
long ChatId,
|
long ChatId,
|
||||||
int MessageId);
|
int MessageId);
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
internal sealed record VoteProposalDto(
|
internal sealed record VoteProposalDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SessionId,
|
Guid SessionId,
|
||||||
@@ -32,17 +27,6 @@ internal sealed record VoteProposalDto(
|
|||||||
int? ConfirmationMessageId,
|
int? ConfirmationMessageId,
|
||||||
int? BatchMessageId);
|
int? BatchMessageId);
|
||||||
|
|
||||||
internal sealed record VoteCountDto(int Total, int Approved);
|
|
||||||
|
|
||||||
// ── Handler ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles "✅ Согласен" / "❌ Против" votes on a reschedule proposal.
|
|
||||||
///
|
|
||||||
/// If anyone votes no → proposal rejected, old time stays.
|
|
||||||
/// If all vote yes → session time updated, batch message re-rendered,
|
|
||||||
/// session status reset to Planned so confirmation triggers work correctly.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class HandleRescheduleVoteHandler(
|
public sealed class HandleRescheduleVoteHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
@@ -53,12 +37,15 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
|
||||||
// 1. Load proposal + session info
|
|
||||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.proposed_at AS ProposedAt,
|
SELECT rp.id AS Id,
|
||||||
s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
rp.session_id AS SessionId,
|
||||||
s.batch_id AS BatchId, s.status AS SessionStatus,
|
rp.proposed_at AS ProposedAt,
|
||||||
|
s.title AS Title,
|
||||||
|
s.scheduled_at AS CurrentScheduledAt,
|
||||||
|
s.batch_id AS BatchId,
|
||||||
|
s.status AS SessionStatus,
|
||||||
s.confirmation_message_id AS ConfirmationMessageId,
|
s.confirmation_message_id AS ConfirmationMessageId,
|
||||||
s.batch_message_id AS BatchMessageId,
|
s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId
|
||||||
@@ -72,12 +59,13 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
if (proposal is null)
|
if (proposal is null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await bot.AnswerCallbackQuery(
|
||||||
"Голосование уже завершено или не найдено.", cancellationToken: ct);
|
command.CallbackQueryId,
|
||||||
|
"Голосование уже завершено или не найдено.",
|
||||||
|
cancellationToken: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Verify voter is a participant of this session
|
|
||||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||||
"""
|
"""
|
||||||
SELECT p.id
|
SELECT p.id
|
||||||
@@ -92,23 +80,52 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
if (playerId is null)
|
if (playerId is null)
|
||||||
{
|
{
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await bot.AnswerCallbackQuery(
|
||||||
"Вы не являетесь участником этой сессии.", cancellationToken: ct);
|
command.CallbackQueryId,
|
||||||
|
"Вы не являетесь участником этой сессии.",
|
||||||
|
cancellationToken: ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Record vote (upsert)
|
await connection.ExecuteAsync(
|
||||||
var inserted = await connection.ExecuteAsync(
|
|
||||||
"""
|
"""
|
||||||
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
|
INSERT INTO reschedule_votes (proposal_id, player_id, vote)
|
||||||
VALUES (@ProposalId, @PlayerId, @Vote)
|
VALUES (@ProposalId, @PlayerId, @Vote)
|
||||||
ON CONFLICT (proposal_id, player_id) DO UPDATE SET vote = EXCLUDED.vote, voted_at = now()
|
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||||
|
SET vote = EXCLUDED.vote,
|
||||||
|
voted_at = now()
|
||||||
""",
|
""",
|
||||||
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
new { command.ProposalId, PlayerId = playerId.Value, command.Vote },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// 4. Handle "no" vote — immediately reject
|
var participants = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
||||||
if (command.Vote == "no")
|
? new List<VoteParticipantDto>()
|
||||||
|
: (await connection.QueryAsync<VoteParticipantDto>(
|
||||||
|
"""
|
||||||
|
SELECT p.id AS PlayerId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername
|
||||||
|
FROM session_participants sp
|
||||||
|
JOIN players p ON p.id = sp.player_id
|
||||||
|
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
||||||
|
""",
|
||||||
|
new { proposal.SessionId },
|
||||||
|
transaction)).ToList();
|
||||||
|
|
||||||
|
var approvedPlayerIds = command.Vote.Equals("no", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? new HashSet<Guid>()
|
||||||
|
: (await connection.QueryAsync<Guid>(
|
||||||
|
"""
|
||||||
|
SELECT player_id
|
||||||
|
FROM reschedule_votes
|
||||||
|
WHERE proposal_id = @ProposalId AND vote = 'yes'
|
||||||
|
""",
|
||||||
|
new { command.ProposalId },
|
||||||
|
transaction)).ToHashSet();
|
||||||
|
|
||||||
|
var decision = RescheduleVoteRules.Evaluate(command.Vote, participants.Count, approvedPlayerIds.Count);
|
||||||
|
|
||||||
|
if (decision.Outcome == RescheduleVoteOutcome.Rejected)
|
||||||
{
|
{
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
|
"UPDATE reschedule_proposals SET status = 'Rejected' WHERE id = @Id",
|
||||||
@@ -117,12 +134,10 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// Get voter's name
|
|
||||||
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
|
var voterName = await connection.QuerySingleOrDefaultAsync<string>(
|
||||||
"SELECT display_name FROM players WHERE telegram_id = @TgId",
|
"SELECT display_name FROM players WHERE telegram_id = @TelegramUserId",
|
||||||
new { TgId = command.TelegramUserId });
|
new { command.TelegramUserId });
|
||||||
|
|
||||||
// Update voting message — show rejection
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.EditMessageText(
|
await bot.EditMessageText(
|
||||||
@@ -137,38 +152,15 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
logger.LogWarning(ex, "Failed to update vote message after rejection");
|
logger.LogWarning(ex, "Failed to update vote message after rejection");
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Вы проголосовали против переноса.", cancellationToken: ct);
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||||||
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
|
logger.LogInformation("Reschedule proposal {ProposalId} rejected by player {PlayerId}", command.ProposalId, playerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Handle "yes" vote — check if all approved
|
if (decision.ShouldRescheduleSession)
|
||||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
|
||||||
"""
|
|
||||||
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
|
||||||
FROM session_participants sp
|
|
||||||
JOIN players p ON p.id = sp.player_id
|
|
||||||
WHERE sp.session_id = @SessionId AND sp.is_gm = false
|
|
||||||
""",
|
|
||||||
new { proposal.SessionId },
|
|
||||||
transaction)).ToList();
|
|
||||||
|
|
||||||
var approvedPlayerIds = (await connection.QueryAsync<Guid>(
|
|
||||||
"""
|
|
||||||
SELECT player_id FROM reschedule_votes
|
|
||||||
WHERE proposal_id = @ProposalId AND vote = 'yes'
|
|
||||||
""",
|
|
||||||
new { command.ProposalId },
|
|
||||||
transaction)).ToHashSet();
|
|
||||||
|
|
||||||
var allApproved = approvedPlayerIds.Count == participants.Count;
|
|
||||||
|
|
||||||
if (allApproved)
|
|
||||||
{
|
{
|
||||||
// 6. All approved — reschedule!
|
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero);
|
||||||
var newTime = new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero); // ProposedAt is stored in UTC
|
|
||||||
|
|
||||||
// Update session time and reset status to Planned for fresh notification cycle
|
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
@@ -187,19 +179,21 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
new { Id = command.ProposalId },
|
new { Id = command.ProposalId },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// Reset all participant RSVP to Pending for the new confirmation cycle
|
if (decision.ShouldResetParticipantRsvps)
|
||||||
await connection.ExecuteAsync(
|
{
|
||||||
"""
|
await connection.ExecuteAsync(
|
||||||
UPDATE session_participants
|
"""
|
||||||
SET rsvp_status = 'Pending', responded_at = NULL
|
UPDATE session_participants
|
||||||
WHERE session_id = @SessionId AND is_gm = false
|
SET rsvp_status = 'Pending',
|
||||||
""",
|
responded_at = NULL
|
||||||
new { proposal.SessionId },
|
WHERE session_id = @SessionId AND is_gm = false
|
||||||
transaction);
|
""",
|
||||||
|
new { proposal.SessionId },
|
||||||
|
transaction);
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
// Update voting message — show approval
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await bot.EditMessageText(
|
await bot.EditMessageText(
|
||||||
@@ -214,21 +208,24 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
logger.LogWarning(ex, "Failed to update vote message after approval");
|
logger.LogWarning(ex, "Failed to update vote message after approval");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-render batch message
|
|
||||||
await TryUpdateBatchMessage(proposal, ct);
|
await TryUpdateBatchMessage(proposal, ct);
|
||||||
|
|
||||||
logger.LogInformation("Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
|
logger.LogInformation(
|
||||||
proposal.SessionId, newTime, command.ProposalId);
|
"Session {SessionId} rescheduled to {NewTime} (proposal {ProposalId})",
|
||||||
|
proposal.SessionId,
|
||||||
|
newTime,
|
||||||
|
command.ProposalId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Not all voted yet — update the voting message to show progress
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
proposal.Title, proposal.CurrentScheduledAt,
|
proposal.Title,
|
||||||
|
proposal.CurrentScheduledAt,
|
||||||
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
|
new DateTimeOffset(proposal.ProposedAt, TimeSpan.Zero),
|
||||||
participants, approvedPlayerIds);
|
participants,
|
||||||
|
approvedPlayerIds);
|
||||||
|
|
||||||
var keyboard = new InlineKeyboardMarkup([
|
var keyboard = new InlineKeyboardMarkup([
|
||||||
[
|
[
|
||||||
@@ -253,15 +250,9 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.AnswerCallbackQuery(command.CallbackQueryId,
|
await bot.AnswerCallbackQuery(command.CallbackQueryId, decision.CallbackText, cancellationToken: ct);
|
||||||
allApproved ? "Вы подтвердили перенос! Все согласны — время обновлено." : "Вы подтвердили перенос!",
|
|
||||||
cancellationToken: ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Re-renders the batch schedule message to reflect the updated session time.
|
|
||||||
/// If batch_message_id is stored, edits the original message. Otherwise sends a notification.
|
|
||||||
/// </summary>
|
|
||||||
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
|
private async Task TryUpdateBatchMessage(VoteProposalDto proposal, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -274,7 +265,9 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
"""
|
"""
|
||||||
SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
SELECT sp.session_id AS SessionId,
|
||||||
|
p.display_name AS DisplayName,
|
||||||
|
p.telegram_username AS TelegramUsername
|
||||||
FROM session_participants sp
|
FROM session_participants sp
|
||||||
JOIN players p ON sp.player_id = p.id
|
JOIN players p ON sp.player_id = p.id
|
||||||
JOIN sessions s ON sp.session_id = s.id
|
JOIN sessions s ON sp.session_id = s.id
|
||||||
@@ -285,7 +278,6 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
|
|
||||||
if (proposal.BatchMessageId.HasValue)
|
if (proposal.BatchMessageId.HasValue)
|
||||||
{
|
{
|
||||||
// Edit the original batch schedule message in-place
|
|
||||||
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
var renderResult = SessionBatchRenderer.Render(proposal.Title, batchSessions, batchParticipants);
|
||||||
|
|
||||||
await bot.EditMessageText(
|
await bot.EditMessageText(
|
||||||
@@ -298,10 +290,9 @@ public sealed class HandleRescheduleVoteHandler(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fallback for sessions created before V005 migration (no batch_message_id)
|
|
||||||
await bot.SendMessage(
|
await bot.SendMessage(
|
||||||
chatId: proposal.TelegramChatId,
|
chatId: proposal.TelegramChatId,
|
||||||
text: $"📢 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
|
text: $"📣 Расписание обновлено! Сессия «{proposal.Title}» перенесена на <b>{proposal.ProposedAt.FormatMoscow()}</b> (МСК).",
|
||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
internal enum RescheduleVoteOutcome
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Rejected,
|
||||||
|
Approved
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RescheduleVoteDecision(
|
||||||
|
RescheduleVoteOutcome Outcome,
|
||||||
|
string CallbackText,
|
||||||
|
bool ShouldRescheduleSession,
|
||||||
|
bool ShouldResetParticipantRsvps);
|
||||||
|
|
||||||
|
internal static class RescheduleVoteRules
|
||||||
|
{
|
||||||
|
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
|
||||||
|
{
|
||||||
|
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new RescheduleVoteDecision(
|
||||||
|
Outcome: RescheduleVoteOutcome.Rejected,
|
||||||
|
CallbackText: "\u0412\u044b \u043f\u0440\u043e\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u0430\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0430.",
|
||||||
|
ShouldRescheduleSession: false,
|
||||||
|
ShouldResetParticipantRsvps: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var everyoneApproved = approvedParticipants == totalParticipants;
|
||||||
|
|
||||||
|
return new RescheduleVoteDecision(
|
||||||
|
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
|
||||||
|
CallbackText: everyoneApproved
|
||||||
|
? "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441! \u0412\u0441\u0435 \u0441\u043e\u0433\u043b\u0430\u0441\u043d\u044b \u2014 \u0432\u0440\u0435\u043c\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e."
|
||||||
|
: "\u0412\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043d\u043e\u0441!",
|
||||||
|
ShouldRescheduleSession: everyoneApproved,
|
||||||
|
ShouldResetParticipantRsvps: everyoneApproved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Aspire.Npgsql" Version="13.2.1" />
|
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
|
||||||
<PackageReference Include="Dapper" Version="2.1.72" />
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
|
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
|
||||||
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
|
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Logging;
|
||||||
|
|
||||||
|
public static partial class SecretRedactor
|
||||||
|
{
|
||||||
|
public static string RedactConnectionString(string? connectionString)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var builder = new NpgsqlConnectionStringBuilder(connectionString);
|
||||||
|
if (!string.IsNullOrWhiteSpace(builder.Password))
|
||||||
|
{
|
||||||
|
builder.Password = "***";
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
return RedactText(connectionString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string RedactText(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SecretKeyValueRegex().Replace(
|
||||||
|
text,
|
||||||
|
static match => $"{match.Groups["key"].Value}={GetRedactedValue()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRedactedValue() => "***";
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(?<key>password|pwd|passwd|token|secret|api[-_]?key)\s*=\s*(?<value>[^;\s,]+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex SecretKeyValueRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Telegram.Bot.Types;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public interface ITelegramUpdateHandler
|
||||||
|
{
|
||||||
|
Task RouteAsync(Update update, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public interface ITelegramUpdateSource
|
||||||
|
{
|
||||||
|
Task<Update[]> GetUpdatesAsync(
|
||||||
|
int offset,
|
||||||
|
int? limit = null,
|
||||||
|
int? timeout = null,
|
||||||
|
IEnumerable<UpdateType>? allowedUpdates = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using Telegram.Bot;
|
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using Telegram.Bot.Types.Enums;
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
@@ -9,35 +8,21 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
|
/// Stateless — all state is in PostgreSQL. Safe to restart at any time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TelegramBotService(
|
public sealed class TelegramBotService(
|
||||||
ITelegramBotClient bot,
|
ITelegramUpdateSource updateSource,
|
||||||
UpdateRouter router,
|
ITelegramUpdateHandler updateHandler,
|
||||||
ILogger<TelegramBotService> logger) : BackgroundService
|
ILogger<TelegramBotService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Telegram bot polling started");
|
logger.LogInformation("Telegram bot polling started");
|
||||||
|
|
||||||
// Skip any pending updates from before this startup
|
var offset = await GetStartupOffsetAsync(stoppingToken);
|
||||||
try
|
|
||||||
{
|
|
||||||
var pending = await bot.GetUpdates(offset: -1, limit: 1, cancellationToken: stoppingToken);
|
|
||||||
if (pending.Length > 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Skipped {Count} pending update(s)", pending[^1].Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Failed to clear pending updates, continuing anyway");
|
|
||||||
}
|
|
||||||
|
|
||||||
var offset = 0;
|
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var updates = await bot.GetUpdates(
|
var updates = await updateSource.GetUpdatesAsync(
|
||||||
offset: offset,
|
offset: offset,
|
||||||
timeout: 30,
|
timeout: 30,
|
||||||
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
|
allowedUpdates: [UpdateType.Message, UpdateType.CallbackQuery],
|
||||||
@@ -47,7 +32,7 @@ public sealed class TelegramBotService(
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await router.RouteAsync(update, stoppingToken);
|
await updateHandler.RouteAsync(update, stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -70,4 +55,33 @@ public sealed class TelegramBotService(
|
|||||||
|
|
||||||
logger.LogInformation("Telegram bot polling stopped");
|
logger.LogInformation("Telegram bot polling stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<int> GetStartupOffsetAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pending = await updateSource.GetUpdatesAsync(
|
||||||
|
offset: -1,
|
||||||
|
limit: 1,
|
||||||
|
cancellationToken: stoppingToken);
|
||||||
|
|
||||||
|
if (pending.Length == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startupOffset = pending[^1].Id + 1;
|
||||||
|
logger.LogInformation(
|
||||||
|
"Skipping pending updates through {LastPendingUpdateId}; starting polling from offset {StartupOffset}",
|
||||||
|
pending[^1].Id,
|
||||||
|
startupOffset);
|
||||||
|
|
||||||
|
return startupOffset;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Failed to determine startup offset, continuing from offset 0");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Telegram.Bot;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public sealed class TelegramUpdateSource(ITelegramBotClient bot) : ITelegramUpdateSource
|
||||||
|
{
|
||||||
|
public Task<Update[]> GetUpdatesAsync(
|
||||||
|
int offset,
|
||||||
|
int? limit = null,
|
||||||
|
int? timeout = null,
|
||||||
|
IEnumerable<UpdateType>? allowedUpdates = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
bot.GetUpdates(
|
||||||
|
offset: offset,
|
||||||
|
limit: limit,
|
||||||
|
timeout: timeout,
|
||||||
|
allowedUpdates: allowedUpdates,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ public sealed class UpdateRouter(
|
|||||||
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
|
||||||
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
HandleRescheduleVoteHandler rescheduleVoteHandler,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
ILogger<UpdateRouter> logger)
|
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
|
||||||
{
|
{
|
||||||
public async Task RouteAsync(Update update, CancellationToken ct)
|
public async Task RouteAsync(Update update, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
|||||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
using GmRelay.Bot.Infrastructure.Database;
|
using GmRelay.Bot.Infrastructure.Database;
|
||||||
|
using GmRelay.Bot.Infrastructure.Logging;
|
||||||
using GmRelay.Bot.Infrastructure.Scheduling;
|
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
using GmRelay.Bot.Infrastructure.Telegram;
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -20,11 +21,16 @@ builder.AddServiceDefaults();
|
|||||||
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
|
||||||
{
|
{
|
||||||
var config = sp.GetRequiredService<IConfiguration>();
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
|
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
|
||||||
var connectionString = config.GetConnectionString("gmrelaydb")
|
var connectionString = config.GetConnectionString("gmrelaydb")
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
|
"ConnectionStrings:gmrelaydb is required. Set via environment variable ConnectionStrings__gmrelaydb.");
|
||||||
|
|
||||||
Console.WriteLine($"[DBG] Master ConnectionString => {connectionString}");
|
var logger = loggerFactory.CreateLogger("GmRelay.Bot.Startup");
|
||||||
|
logger.LogInformation(
|
||||||
|
"Configured PostgreSQL data source with connection string {ConnectionString}",
|
||||||
|
SecretRedactor.RedactConnectionString(connectionString));
|
||||||
|
|
||||||
return NpgsqlDataSource.Create(connectionString);
|
return NpgsqlDataSource.Create(connectionString);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,6 +46,7 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
|||||||
"Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
|
"Telegram:BotToken is required. Set via environment variable Telegram__BotToken or appsettings.json.");
|
||||||
return new TelegramBotClient(token);
|
return new TelegramBotClient(token);
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
||||||
|
|
||||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||||
@@ -57,6 +64,7 @@ builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
|
|||||||
|
|
||||||
// ── Telegram infrastructure ──────────────────────────────────────────
|
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<UpdateRouter>();
|
builder.Services.AddSingleton<UpdateRouter>();
|
||||||
|
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
||||||
builder.Services.AddHostedService<TelegramBotService>();
|
builder.Services.AddHostedService<TelegramBotService>();
|
||||||
|
|
||||||
// ── Session scheduler ────────────────────────────────────────────────
|
// ── Session scheduler ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||||
@@ -11,7 +11,7 @@ public static class MoscowTime
|
|||||||
public static string FormatMoscow(this DateTimeOffset utc)
|
public static string FormatMoscow(this DateTimeOffset utc)
|
||||||
=> utc.ToOffset(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
=> utc.ToOffset(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
||||||
|
|
||||||
public static DateTime ToMoscow(this DateTime utcDt) => utcDt.Add(MoscowOffset);
|
public static DateTime ToMoscow(this DateTime utcDt) => DateTime.SpecifyKind(utcDt.Add(MoscowOffset), DateTimeKind.Unspecified);
|
||||||
|
|
||||||
public static string FormatMoscow(this DateTime utcDt)
|
public static string FormatMoscow(this DateTime utcDt)
|
||||||
=> utcDt.Add(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
=> utcDt.Add(MoscowOffset).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="GM-Relay — панель управления для Мастеров Игры. Управляйте сессиями настольных ролевых игр через Telegram." />
|
||||||
|
<meta name="theme-color" content="#0a0e1a" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<ResourcePreloader />
|
<ResourcePreloader />
|
||||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["GmRelay.Web.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
@@ -18,6 +22,21 @@
|
|||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
|
<script>
|
||||||
|
window.loadTelegramWidget = function (botUsername, authUrl) {
|
||||||
|
var container = document.getElementById('telegram-login-container');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
||||||
|
script.setAttribute('data-telegram-login', botUsername);
|
||||||
|
script.setAttribute('data-size', 'large');
|
||||||
|
script.setAttribute('data-auth-url', authUrl);
|
||||||
|
script.setAttribute('data-request-access', 'write');
|
||||||
|
container.appendChild(script);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="sidebar">
|
<aside class="sidebar">
|
||||||
<NavMenu />
|
<NavMenu />
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
<main>
|
<div class="main-area">
|
||||||
<div class="top-row px-4">
|
<article class="content">
|
||||||
<a href="https://github.com/Toutsu/GmRelayBot" target="_blank">О проекте</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article class="content px-4">
|
|
||||||
@Body
|
@Body
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
|
|||||||
@@ -1,86 +1,39 @@
|
|||||||
.page {
|
.page {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
min-height: 100vh;
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
width: var(--sidebar-width);
|
||||||
}
|
background: linear-gradient(180deg, #0f1629 0%, #1a0a2e 100%);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
.top-row {
|
position: fixed;
|
||||||
background-color: #f7f7f7;
|
top: 0;
|
||||||
border-bottom: 1px solid #d6d5d5;
|
left: 0;
|
||||||
justify-content: flex-end;
|
height: 100vh;
|
||||||
height: 3.5rem;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
transition: transform var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
.main-area {
|
||||||
white-space: nowrap;
|
flex: 1;
|
||||||
margin-left: 1.5rem;
|
margin-left: var(--sidebar-width);
|
||||||
text-decoration: none;
|
min-height: 100vh;
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a:first-child {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640.98px) {
|
|
||||||
.top-row {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
.content {
|
||||||
.page {
|
padding: 1.5rem 2rem;
|
||||||
flex-direction: row;
|
max-width: 100%;
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 250px;
|
|
||||||
height: 100vh;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row.auth ::deep a:first-child {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row, article {
|
|
||||||
padding-left: 2rem !important;
|
|
||||||
padding-right: 1.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Error UI === */
|
||||||
#blazor-error-ui {
|
#blazor-error-ui {
|
||||||
color-scheme: light only;
|
background: var(--bg-secondary);
|
||||||
background: lightyellow;
|
border-top: 1px solid var(--border-color);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: none;
|
display: none;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -88,11 +41,44 @@ main {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .reload {
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
#blazor-error-ui .dismiss {
|
#blazor-error-ui .dismiss {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.75rem;
|
right: 0.75rem;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Mobile Responsive === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
|
.content {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,41 +1,73 @@
|
|||||||
<div class="top-row ps-3 navbar navbar-dark">
|
@inject NavigationManager Navigation
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="">GM-Relay Web</a>
|
<div class="nav-header">
|
||||||
</div>
|
<a class="nav-brand" href="">
|
||||||
|
<span class="nav-brand-icon">🎲</span>
|
||||||
|
<span class="nav-brand-text">GM-Relay</span>
|
||||||
|
</a>
|
||||||
|
<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">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="checkbox" title="Навигационное меню" class="navbar-toggler" />
|
<nav class="nav-body @(isOpen ? "open" : "")">
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="nav-section">
|
||||||
|
<NavLink class="nav-item" href="" Match="NavLinkMatch.All" @onclick="CloseMenu">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
Панель управления
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
<div class="nav-footer">
|
||||||
<nav class="nav flex-column">
|
<div class="nav-user">
|
||||||
<AuthorizeView>
|
<div class="nav-user-avatar">
|
||||||
<Authorized>
|
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
|
||||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Панель управления
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3 mt-auto">
|
|
||||||
<div class="nav-link text-light">
|
|
||||||
<span class="bi bi-person-fill" aria-hidden="true"></span> @context.User.Identity?.Name
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="nav-user-name">@context.User.Identity?.Name</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item px-3">
|
|
||||||
<form action="/auth/logout" method="post">
|
<form action="/auth/logout" method="post">
|
||||||
<AntiforgeryToken />
|
<AntiforgeryToken />
|
||||||
<button type="submit" class="nav-link btn btn-link text-light text-start w-100 p-0 shadow-none border-0">
|
<button type="submit" class="nav-logout-btn">
|
||||||
<span class="bi bi-box-arrow-right" aria-hidden="true"></span> Выйти
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</button>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1-2 2h4"/>
|
||||||
</form>
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
</div>
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
</Authorized>
|
</svg>
|
||||||
<NotAuthorized>
|
Выйти
|
||||||
<div class="nav-item px-3">
|
</button>
|
||||||
<NavLink class="nav-link" href="login">
|
</form>
|
||||||
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Войти
|
|
||||||
</NavLink>
|
<div class="nav-version">v1.1.0</div>
|
||||||
</div>
|
</div>
|
||||||
</NotAuthorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
<NotAuthorized>
|
||||||
</nav>
|
<div class="nav-section">
|
||||||
</div>
|
<NavLink class="nav-item" href="login" @onclick="CloseMenu">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||||
|
<polyline points="10 17 15 12 10 7"/>
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Войти
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool isOpen;
|
||||||
|
|
||||||
|
private void ToggleMenu() => isOpen = !isOpen;
|
||||||
|
private void CloseMenu() => isOpen = false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,105 +1,194 @@
|
|||||||
.navbar-toggler {
|
/* === Nav Header === */
|
||||||
appearance: none;
|
.nav-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand-text {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-toggle {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.375rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 3.5rem;
|
transition: all var(--transition-fast);
|
||||||
height: 2.5rem;
|
|
||||||
color: white;
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 1rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-toggler:checked {
|
.nav-toggle:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-row {
|
/* === Nav Body === */
|
||||||
min-height: 3.5rem;
|
.nav-body {
|
||||||
background-color: rgba(0,0,0,0.4);
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.nav-section {
|
||||||
font-size: 1.1rem;
|
padding: 0 0.75rem;
|
||||||
}
|
flex: 1;
|
||||||
|
|
||||||
.bi {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
top: -1px;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-house-door-fill-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-plus-square-fill-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-list-nested-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Nav Items === */
|
||||||
.nav-item {
|
.nav-item {
|
||||||
font-size: 0.9rem;
|
display: flex;
|
||||||
padding-bottom: 0.5rem;
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:first-of-type {
|
.nav-item:hover {
|
||||||
padding-top: 1rem;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
}
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item:last-of-type {
|
.nav-item.active,
|
||||||
padding-bottom: 1rem;
|
.nav-item ::deep a.active {
|
||||||
}
|
background: rgba(124, 58, 237, 0.15);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid rgba(124, 58, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item ::deep .nav-link {
|
.nav-icon {
|
||||||
color: #d7d7d7;
|
width: 1.125rem;
|
||||||
background: none;
|
height: 1.125rem;
|
||||||
border: none;
|
flex-shrink: 0;
|
||||||
border-radius: 4px;
|
}
|
||||||
height: 3rem;
|
|
||||||
|
/* === Nav Footer === */
|
||||||
|
.nav-footer {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logout-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logout-btn:hover {
|
||||||
|
background: var(--status-danger-bg);
|
||||||
|
color: var(--status-danger);
|
||||||
|
border-color: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-version {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Mobile === */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 3rem;
|
justify-content: center;
|
||||||
width: 100%;
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 200;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item ::deep a.active {
|
.nav-body {
|
||||||
background-color: rgba(255,255,255,0.37);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep .nav-link:hover {
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-toggler:checked ~ .nav-scrollable {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
|
||||||
.navbar-toggler {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-scrollable {
|
.nav-body.open {
|
||||||
/* Never collapse the sidebar for wide screens */
|
display: flex;
|
||||||
display: block;
|
}
|
||||||
|
|
||||||
/* Allow sidebar to scroll for tall menus */
|
.nav-header {
|
||||||
height: calc(100vh - 3.5rem);
|
padding-right: 0.75rem;
|
||||||
overflow-y: auto;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.nav-body {
|
||||||
|
height: calc(100vh - 4.5rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@page "/access-denied"
|
||||||
|
|
||||||
|
<PageTitle>Доступ запрещен — GM-Relay</PageTitle>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="glass-card" style="max-width: 640px;">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⛔</div>
|
||||||
|
<div class="empty-state-title">Доступ запрещен</div>
|
||||||
|
<p class="empty-state-text">Эта группа или сессия недоступна для вашей учётной записи.</p>
|
||||||
|
<a href="/" class="btn-gm btn-gm-primary">← На главную</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (HttpContext is not null && !HttpContext.Response.HasStarted)
|
||||||
|
{
|
||||||
|
HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,61 +2,71 @@
|
|||||||
@using GmRelay.Web.Services
|
@using GmRelay.Web.Services
|
||||||
@using GmRelay.Shared.Domain
|
@using GmRelay.Shared.Domain
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject SessionService SessionService
|
@inject AuthorizedSessionService SessionService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Редактирование сессии - GM-Relay</PageTitle>
|
<PageTitle>Редактирование сессии — GM-Relay</PageTitle>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="page-container">
|
||||||
<nav aria-label="breadcrumb">
|
<ul class="gm-breadcrumb animate-fade-in">
|
||||||
<ol class="breadcrumb">
|
<li><a href="/">Главная</a></li>
|
||||||
<li class="breadcrumb-item"><a href="/">Главная</a></li>
|
<li class="active">Редактирование сессии</li>
|
||||||
<li class="breadcrumb-item active">Редактирование сессии</li>
|
</ul>
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<h2>Редактирование сессии</h2>
|
<div class="page-header animate-fade-in">
|
||||||
|
<h2>✏️ Редактирование сессии</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (session == null)
|
@if (session == null)
|
||||||
{
|
{
|
||||||
<p>Загрузка деталей сессии...</p>
|
<div class="glass-card" style="padding: 2rem;">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 1.5rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 100%; height: 2.5rem; margin-bottom: 1.5rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 30%; height: 2.5rem;"></div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="card shadow-sm mt-4">
|
<div class="glass-card animate-slide-up" style="max-width: 640px;">
|
||||||
<div class="card-body">
|
<EditForm Model="@model" OnValidSubmit="HandleSubmit">
|
||||||
<EditForm Model="@model" OnValidSubmit="HandleSubmit">
|
<div class="gm-form-group">
|
||||||
<div class="mb-3">
|
<label class="gm-form-label">Название игры</label>
|
||||||
<label class="form-label font-weight-bold">Название игры</label>
|
<InputText @bind-Value="model.Title" class="gm-form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
|
||||||
<InputText @bind-Value="model.Title" class="form-control" placeholder="например, D&D 5e: Dragon's Hoard" />
|
<div class="gm-form-hint">Изменение этого поля обновит все сессии в одной группе.</div>
|
||||||
<div class="form-text">Изменение этого поля обновит все сессии в одной группе.</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="gm-form-group">
|
||||||
<label class="form-label font-weight-bold">Запланированное время (МСК UTC+3)</label>
|
<label class="gm-form-label">Запланированное время (МСК, UTC+3)</label>
|
||||||
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="form-control" />
|
<input type="datetime-local" @bind="model.ScheduledAtLocal" class="gm-form-control" />
|
||||||
<div class="form-text">Текущее: @session.ScheduledAt.FormatMoscow()</div>
|
<div class="gm-form-hint">Текущее: @session.ScheduledAt.FormatMoscow()</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="gm-form-group">
|
||||||
<label class="form-label font-weight-bold">Ссылка для подключения</label>
|
<label class="gm-form-label">Ссылка для подключения</label>
|
||||||
<InputText @bind-Value="model.JoinLink" class="form-control" placeholder="Ссылка на Discord или VTT" />
|
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||||
<button type="submit" class="btn btn-success" disabled="@isSubmitting">
|
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||||
@(isSubmitting ? "Сохранение..." : "Сохранить изменения")
|
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary ms-2" @onclick="GoBack">Отмена</button>
|
<button type="button" class="btn-gm btn-gm-outline" @onclick="GoBack">
|
||||||
</div>
|
Отмена
|
||||||
</EditForm>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</EditForm>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger mt-3">@errorMessage</div>
|
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem; max-width: 640px;">
|
||||||
|
⚠️ @errorMessage
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,19 +75,28 @@
|
|||||||
[Parameter] public Guid SessionId { get; set; }
|
[Parameter] public Guid SessionId { get; set; }
|
||||||
private WebSession? session;
|
private WebSession? session;
|
||||||
private SessionEditModel model = new();
|
private SessionEditModel model = new();
|
||||||
private bool isSubmitting = false;
|
private bool isSubmitting;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
session = await SessionService.GetSessionAsync(SessionId);
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
if (session != null)
|
if (!authState.User.TryGetTelegramId(out var telegramId))
|
||||||
{
|
{
|
||||||
model.Title = session.Title;
|
Navigation.NavigateTo("/access-denied");
|
||||||
// Convert UTC to Moscow for the picker
|
return;
|
||||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
|
||||||
model.JoinLink = session.JoinLink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.Title = session.Title;
|
||||||
|
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||||
|
model.JoinLink = session.JoinLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleSubmit()
|
private async Task HandleSubmit()
|
||||||
@@ -87,13 +106,22 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// The value from <input type="datetime-local"> is considered as "unspecified" or local to browser.
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
// We treat it as Moscow time (UTC+3) and convert to UTC.
|
if (!authState.User.TryGetTelegramId(out var telegramId))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||||
|
|
||||||
await SessionService.UpdateSessionAsync(SessionId, model.Title, utcTime, model.JoinLink);
|
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink);
|
||||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||||
}
|
}
|
||||||
|
catch (SessionAccessDeniedException)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
errorMessage = "Не удалось сохранить изменения: " + ex.Message;
|
errorMessage = "Не удалось сохранить изменения: " + ex.Message;
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
@page "/Error"
|
@page "/Error"
|
||||||
@using System.Diagnostics
|
@using System.Diagnostics
|
||||||
|
|
||||||
<PageTitle>Ошибка</PageTitle>
|
<PageTitle>Ошибка — GM-Relay</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-danger">Ошибка.</h1>
|
<div class="page-container">
|
||||||
<h2 class="text-danger">Произошла ошибка при обработке вашего запроса.</h2>
|
<div class="error-page">
|
||||||
|
<div class="error-page-icon">⚠️</div>
|
||||||
|
<h1 class="error-page-title">Произошла ошибка</h1>
|
||||||
|
<p class="error-page-text">При обработке вашего запроса что-то пошло не так. Пожалуйста, попробуйте снова.</p>
|
||||||
|
|
||||||
@if (ShowRequestId)
|
@if (ShowRequestId)
|
||||||
{
|
{
|
||||||
<p>
|
<p style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||||
<strong>ID запроса:</strong> <code>@RequestId</code>
|
ID запроса: @RequestId
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h3>Режим разработки</h3>
|
<a href="/" class="btn-gm btn-gm-primary" style="margin-top: 0.5rem;">
|
||||||
<p>
|
← На главную
|
||||||
Переключение на среду <strong>Development</strong> отобразит более подробную информацию о произошедшей ошибке.
|
</a>
|
||||||
</p>
|
</div>
|
||||||
<p>
|
</div>
|
||||||
<strong>Среда Development не должна быть включена для развернутых приложений.</strong>
|
|
||||||
Это может привести к отображению конфиденциальной информации из исключений конечным пользователям.
|
|
||||||
Для локальной отладки включите среду <strong>Development</strong>, установив переменную среды <strong>ASPNETCORE_ENVIRONMENT</strong> в значение <strong>Development</strong>
|
|
||||||
и перезапустите приложение.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@code{
|
@code{
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
|
|||||||
@@ -2,63 +2,111 @@
|
|||||||
@using GmRelay.Web.Services
|
@using GmRelay.Web.Services
|
||||||
@using GmRelay.Shared.Domain
|
@using GmRelay.Shared.Domain
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject SessionService SessionService
|
@inject AuthorizedSessionService SessionService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Сессии группы - GM-Relay</PageTitle>
|
<PageTitle>Сессии группы — GM-Relay</PageTitle>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="page-container">
|
||||||
<nav aria-label="breadcrumb">
|
<ul class="gm-breadcrumb animate-fade-in">
|
||||||
<ol class="breadcrumb">
|
<li><a href="/">Главная</a></li>
|
||||||
<li class="breadcrumb-item"><a href="/">Главная</a></li>
|
<li class="active">Сессии группы</li>
|
||||||
<li class="breadcrumb-item active">Сессии группы</li>
|
</ul>
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<h2>Предстоящие игры</h2>
|
<div class="page-header animate-fade-in">
|
||||||
|
<h2>📅 Предстоящие игры</h2>
|
||||||
<div class="mt-4">
|
|
||||||
@if (sessions == null)
|
|
||||||
{
|
|
||||||
<p>Загрузка сессий...</p>
|
|
||||||
}
|
|
||||||
else if (sessions.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="alert alert-info">Для этой группы не найдено предстоящих сессий.</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Название</th>
|
|
||||||
<th>Время (МСК)</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Ссылка</th>
|
|
||||||
<th>Действие</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var session in sessions)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@session.Title</td>
|
|
||||||
<td>@session.ScheduledAt.FormatMoscow()</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
|
||||||
</td>
|
|
||||||
<td><a href="@session.JoinLink" target="_blank" class="text-truncate d-inline-block" style="max-width: 150px;">Ссылка</a></td>
|
|
||||||
<td>
|
|
||||||
<a href="/session/edit/@session.Id" class="btn btn-sm btn-outline-secondary">Изменить</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (sessions == null)
|
||||||
|
{
|
||||||
|
<div class="glass-card" style="padding: 2rem;">
|
||||||
|
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 1rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 0.75rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
|
||||||
|
<div class="skeleton skeleton-text" style="width: 50%;"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (sessions.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="glass-card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🎯</div>
|
||||||
|
<div class="empty-state-title">Нет предстоящих сессий</div>
|
||||||
|
<p class="empty-state-text">Для этой группы пока не запланировано игровых сессий.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* Desktop table *@
|
||||||
|
<div class="glass-card session-table-desktop animate-slide-up" style="padding: 0; overflow: hidden;">
|
||||||
|
<table class="gm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Время (МСК)</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Ссылка</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
|
||||||
|
<td>@session.ScheduledAt.FormatMoscow()</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="max-width: 150px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
Подключиться ↗
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
|
||||||
|
✏️ Изменить
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Mobile cards *@
|
||||||
|
<div class="session-card-mobile stagger-children">
|
||||||
|
@foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
<div class="session-card">
|
||||||
|
<div class="session-card-header">
|
||||||
|
<span class="session-card-title">@session.Title</span>
|
||||||
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-card-body">
|
||||||
|
<div class="session-card-row">
|
||||||
|
<span>🕐 Время</span>
|
||||||
|
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-card-row">
|
||||||
|
<span>🔗 Ссылка</span>
|
||||||
|
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-card-actions">
|
||||||
|
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||||
|
✏️ Изменить
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -67,15 +115,28 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
sessions = await SessionService.GetUpcomingSessionsAsync(GroupId);
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
if (!authState.User.TryGetTelegramId(out var telegramId))
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
||||||
|
if (sessions is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetStatusClass(string status) => status switch
|
private string GetStatusClass(string status) => status switch
|
||||||
{
|
{
|
||||||
SessionStatus.Confirmed => "bg-success",
|
SessionStatus.Confirmed => "status-success",
|
||||||
SessionStatus.Cancelled => "bg-danger",
|
SessionStatus.Cancelled => "status-danger",
|
||||||
SessionStatus.ConfirmationSent => "bg-warning text-dark",
|
SessionStatus.ConfirmationSent => "status-warning",
|
||||||
_ => "bg-secondary"
|
"Recruiting" => "status-info",
|
||||||
|
"RecruitmentClosed" => "status-info",
|
||||||
|
_ => "status-neutral"
|
||||||
};
|
};
|
||||||
|
|
||||||
private string TranslateStatus(string status) => status switch
|
private string TranslateStatus(string status) => status switch
|
||||||
@@ -83,7 +144,7 @@
|
|||||||
"Recruiting" => "Набор",
|
"Recruiting" => "Набор",
|
||||||
"RecruitmentClosed" => "Набор закрыт",
|
"RecruitmentClosed" => "Набор закрыт",
|
||||||
SessionStatus.Planned => "Запланировано",
|
SessionStatus.Planned => "Запланировано",
|
||||||
SessionStatus.ConfirmationSent => "Ждем подтверждения",
|
SessionStatus.ConfirmationSent => "Ждём подтверждения",
|
||||||
SessionStatus.Confirmed => "Подтверждено",
|
SessionStatus.Confirmed => "Подтверждено",
|
||||||
SessionStatus.Cancelled => "Отменено",
|
SessionStatus.Cancelled => "Отменено",
|
||||||
_ => status
|
_ => status
|
||||||
|
|||||||
@@ -3,48 +3,82 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using GmRelay.Web.Services
|
@using GmRelay.Web.Services
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject SessionService SessionService
|
@inject AuthorizedSessionService SessionService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Панель управления - GM-Relay</PageTitle>
|
<PageTitle>Панель управления — GM-Relay</PageTitle>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="page-container">
|
||||||
<h2>Добро пожаловать, @userName!</h2>
|
<div class="page-header animate-fade-in">
|
||||||
<p class="text-muted">Выберите группу для управления играми.</p>
|
<h2>Добро пожаловать, @userName! 👋</h2>
|
||||||
|
<p>Выберите группу для управления игровыми сессиями.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
@if (groups == null)
|
||||||
@if (groups == null)
|
{
|
||||||
{
|
<div class="card-grid">
|
||||||
<p>Загрузка групп...</p>
|
@for (int i = 0; i < 3; i++)
|
||||||
}
|
{
|
||||||
else if (groups.Count == 0)
|
<div class="skeleton skeleton-card"></div>
|
||||||
{
|
}
|
||||||
<div class="col-12">
|
</div>
|
||||||
<div class="card bg-light">
|
}
|
||||||
<div class="card-body text-center">
|
else if (groups.Count == 0)
|
||||||
<p class="mb-0">У вас еще нет зарегистрированных групп. Сначала добавьте бота в группу Telegram!</p>
|
{
|
||||||
</div>
|
<div class="glass-card">
|
||||||
</div>
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🤖</div>
|
||||||
|
<div class="empty-state-title">Нет зарегистрированных групп</div>
|
||||||
|
<p class="empty-state-text">Добавьте бота GM-Relay в свою группу Telegram, чтобы начать управлять игровыми сессиями.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
else
|
}
|
||||||
{
|
else
|
||||||
|
{
|
||||||
|
<div class="card-grid stagger-children">
|
||||||
@foreach (var group in groups)
|
@foreach (var group in groups)
|
||||||
{
|
{
|
||||||
<div class="col-md-4 mb-3">
|
<div class="glass-card group-card">
|
||||||
<div class="card h-100 shadow-sm">
|
<div class="group-card-icon">🎮</div>
|
||||||
<div class="card-body">
|
<h3 class="group-card-title">@group.Name</h3>
|
||||||
<h5 class="card-title">@group.Name</h5>
|
<p class="group-card-id">ID: @group.TelegramChatId</p>
|
||||||
<p class="card-text text-muted">ID: @group.TelegramChatId</p>
|
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
|
||||||
<a href="/group/@group.Id" class="btn btn-primary">Посмотреть игры</a>
|
Посмотреть игры →
|
||||||
</div>
|
</a>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.group-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-card-id {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<WebGameGroup>? groups;
|
private List<WebGameGroup>? groups;
|
||||||
private string userName = "";
|
private string userName = "";
|
||||||
@@ -55,10 +89,12 @@
|
|||||||
var user = authState.User;
|
var user = authState.User;
|
||||||
userName = user.Identity?.Name ?? "Мастер Игры";
|
userName = user.Identity?.Name ?? "Мастер Игры";
|
||||||
|
|
||||||
var telegramIdClaim = user.FindFirst("TelegramId")?.Value;
|
if (!user.TryGetTelegramId(out var telegramId))
|
||||||
if (long.TryParse(telegramIdClaim, out var telegramId))
|
|
||||||
{
|
{
|
||||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
Navigation.NavigateTo("/access-denied");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,24 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject IConfiguration Configuration
|
@inject IConfiguration Configuration
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<PageTitle>Вход - GM-Relay</PageTitle>
|
<PageTitle>Вход — GM-Relay</PageTitle>
|
||||||
|
|
||||||
<div class="container">
|
<div class="login-page">
|
||||||
<div class="row justify-content-center mt-5">
|
<div class="login-card">
|
||||||
<div class="col-md-6 text-center">
|
<div class="login-logo">🎲</div>
|
||||||
<h3>Панель управления GM-Relay</h3>
|
<h1 class="login-title">GM-Relay</h1>
|
||||||
<p class="text-muted">Пожалуйста, войдите как Мастер Игры для управления сессиями.</p>
|
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
|
||||||
|
|
||||||
<div class="mt-4">
|
@if (Navigation.Uri.Contains("error=auth_failed"))
|
||||||
@if (Navigation.Uri.Contains("error=auth_failed"))
|
{
|
||||||
{
|
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1.5rem; justify-content: center;">
|
||||||
<div class="alert alert-danger">Ошибка аутентификации. Пожалуйста, попробуйте снова.</div>
|
⚠️ Ошибка аутентификации. Пожалуйста, попробуйте снова.
|
||||||
}
|
|
||||||
|
|
||||||
@* Telegram Login Widget *@
|
|
||||||
<div id="telegram-login-container">
|
|
||||||
<script async src="https://telegram.org/js/telegram-widget.js?22"
|
|
||||||
data-telegram-login="@BotUsername"
|
|
||||||
data-size="large"
|
|
||||||
data-auth-url="@AuthUrl"
|
|
||||||
data-request-access="write"></script>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
<div id="telegram-login-container"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,4 +41,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("loadTelegramWidget", BotUsername, AuthUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
@page "/not-found"
|
@page "/not-found"
|
||||||
@layout MainLayout
|
@layout MainLayout
|
||||||
|
|
||||||
<h3>Не найдено</h3>
|
<PageTitle>404 — GM-Relay</PageTitle>
|
||||||
<p>Извините, страница, которую вы ищете, не существует.</p>
|
|
||||||
|
<div class="error-page">
|
||||||
|
<div class="error-page-icon">🔍</div>
|
||||||
|
<h1 class="error-page-title">Страница не найдена</h1>
|
||||||
|
<p class="error-page-text">Извините, страница, которую вы ищете, не существует или была перемещена.</p>
|
||||||
|
<a href="/" class="btn-gm btn-gm-primary">
|
||||||
|
← Вернуться на главную
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -21,7 +21,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
|||||||
|
|
||||||
// Add Services
|
// Add Services
|
||||||
builder.Services.AddSingleton<TelegramAuthService>();
|
builder.Services.AddSingleton<TelegramAuthService>();
|
||||||
builder.Services.AddSingleton<SessionService>();
|
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||||
|
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||||
|
|
||||||
// Add Bot Client
|
// Add Bot Client
|
||||||
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
||||||
@@ -32,12 +33,17 @@ builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
|||||||
return new TelegramBotClient(token);
|
return new TelegramBotClient(token);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Authentication
|
// Add Authentication with hardened cookie settings
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(options =>
|
.AddCookie(options =>
|
||||||
{
|
{
|
||||||
options.LoginPath = "/login";
|
options.LoginPath = "/login";
|
||||||
options.AccessDeniedPath = "/access-denied";
|
options.AccessDeniedPath = "/access-denied";
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
@@ -58,6 +64,16 @@ if (!app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Security headers middleware
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||||
|
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||||
|
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||||
|
{
|
||||||
|
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||||
|
sessionStore.GetGroupsForGmAsync(gmId);
|
||||||
|
|
||||||
|
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
|
||||||
|
{
|
||||||
|
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId)
|
||||||
|
{
|
||||||
|
var session = await sessionStore.GetSessionAsync(sessionId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink)
|
||||||
|
{
|
||||||
|
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||||
|
{
|
||||||
|
var group = await sessionStore.GetGroupAsync(groupId);
|
||||||
|
return group?.GmTelegramId == gmId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public static class ClaimsPrincipalExtensions
|
||||||
|
{
|
||||||
|
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
|
||||||
|
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public interface ISessionStore
|
||||||
|
{
|
||||||
|
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
||||||
|
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||||
|
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||||
|
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||||
|
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId)
|
||||||
|
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'.");
|
||||||
@@ -11,7 +11,8 @@ public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime Sc
|
|||||||
|
|
||||||
public sealed class SessionService(
|
public sealed class SessionService(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot)
|
ITelegramBotClient bot,
|
||||||
|
ILogger<SessionService> logger) : ISessionStore
|
||||||
{
|
{
|
||||||
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
|
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,14 @@ public sealed class SessionService(
|
|||||||
new { GmId = gmId })).ToList();
|
new { GmId = gmId })).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||||||
|
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
|
||||||
|
new { GroupId = groupId });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
@@ -48,29 +57,41 @@ public sealed class SessionService(
|
|||||||
new { SessionId = sessionId });
|
new { SessionId = sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateSessionAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink)
|
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
await using var transaction = await conn.BeginTransactionAsync();
|
await using var transaction = await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
// 1. Fetch current session with all required columns for WebSession record
|
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||||
var oldSession = await conn.QuerySingleAsync<WebSession>(
|
|
||||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||||
g.telegram_chat_id AS TelegramChatId
|
g.telegram_chat_id AS TelegramChatId
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @Id",
|
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||||
new { Id = sessionId }, transaction);
|
new { Id = sessionId, GroupId = groupId },
|
||||||
|
|
||||||
// 2. Update Session
|
|
||||||
await conn.ExecuteAsync(
|
|
||||||
@"UPDATE sessions SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, updated_at = now()
|
|
||||||
WHERE id = @Id",
|
|
||||||
new { Id = sessionId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
|
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
// 3. Update all sessions in the same batch with new title (optional, usually batch shares title)
|
if (oldSession is null)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedRows = await conn.ExecuteAsync(
|
||||||
|
@"UPDATE sessions
|
||||||
|
SET title = @Title,
|
||||||
|
scheduled_at = @ScheduledAt,
|
||||||
|
join_link = @JoinLink,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = @Id AND group_id = @GroupId",
|
||||||
|
new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
if (updatedRows == 0)
|
||||||
|
{
|
||||||
|
throw new SessionAccessDeniedException(sessionId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
await conn.ExecuteAsync(
|
await conn.ExecuteAsync(
|
||||||
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
|
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
|
||||||
new { Title = title, BatchId = oldSession.BatchId },
|
new { Title = title, BatchId = oldSession.BatchId },
|
||||||
@@ -78,7 +99,6 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
|
|
||||||
// 4. Send Telegram Notification
|
|
||||||
var timeChanged = oldSession.ScheduledAt != scheduledAt;
|
var timeChanged = oldSession.ScheduledAt != scheduledAt;
|
||||||
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
|
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
|
||||||
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
|
||||||
@@ -86,7 +106,6 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||||
|
|
||||||
// 5. Update Original Batch Message
|
|
||||||
if (oldSession.BatchMessageId.HasValue)
|
if (oldSession.BatchMessageId.HasValue)
|
||||||
{
|
{
|
||||||
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
|
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
|
||||||
@@ -121,9 +140,9 @@ public sealed class SessionService(
|
|||||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||||||
replyMarkup: renderResult.Markup);
|
replyMarkup: renderResult.Markup);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Ignore if message too old or same content
|
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,19 +26,18 @@ public sealed class TelegramAuthService(IConfiguration configuration)
|
|||||||
|
|
||||||
var dataCheckString = string.Join("\n", dataCheckList);
|
var dataCheckString = string.Join("\n", dataCheckList);
|
||||||
|
|
||||||
// 2. Compute Secret Key
|
// 2. Compute Secret Key (static method — no IDisposable needed)
|
||||||
using var sha256 = SHA256.Create();
|
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||||
var secretKey = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
|
|
||||||
|
|
||||||
// 3. Compute Hash
|
// 3. Compute Hash (static method — no IDisposable needed)
|
||||||
using var hmac = new HMACSHA256(secretKey);
|
var computedHashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
|
||||||
var computedHashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataCheckString));
|
|
||||||
var computedHash = Convert.ToHexString(computedHashBytes).ToLower();
|
|
||||||
|
|
||||||
if (computedHash != hash.ToString().ToLower())
|
// 4. Timing-safe comparison to prevent timing attacks
|
||||||
|
var hashBytes = Convert.FromHexString(hash.ToString());
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(computedHashBytes, hashBytes))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// 4. Check expiration (auth_date)
|
// 5. Check expiration (auth_date)
|
||||||
if (query.TryGetValue("auth_date", out var authDateStr) && long.TryParse(authDateStr, out var authDate))
|
if (query.TryGetValue("auth_date", out var authDateStr) && long.TryParse(authDateStr, out var authDate))
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
|||||||
+795
-31
@@ -1,60 +1,824 @@
|
|||||||
|
/* ============================================
|
||||||
|
GM-Relay Design System v1.1.0
|
||||||
|
Dark RPG Dashboard Theme
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* --- Google Fonts loaded in App.razor --- */
|
||||||
|
|
||||||
|
/* === CSS Custom Properties === */
|
||||||
|
:root {
|
||||||
|
/* Background */
|
||||||
|
--bg-primary: #0a0e1a;
|
||||||
|
--bg-secondary: #111827;
|
||||||
|
--bg-card: rgba(17, 24, 39, 0.7);
|
||||||
|
--bg-card-hover: rgba(24, 33, 54, 0.85);
|
||||||
|
--bg-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-input: rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent-primary: #7c3aed;
|
||||||
|
--accent-primary-hover: #6d28d9;
|
||||||
|
--accent-secondary: #06b6d4;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #7c3aed 0%, #06b6d4 100%);
|
||||||
|
--accent-gradient-hover: linear-gradient(135deg, #6d28d9 0%, #0891b2 100%);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #f1f5f9;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-accent: #a78bfa;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--status-success: #22c55e;
|
||||||
|
--status-success-bg: rgba(34, 197, 94, 0.15);
|
||||||
|
--status-warning: #f59e0b;
|
||||||
|
--status-warning-bg: rgba(245, 158, 11, 0.15);
|
||||||
|
--status-danger: #ef4444;
|
||||||
|
--status-danger-bg: rgba(239, 68, 68, 0.15);
|
||||||
|
--status-info: #06b6d4;
|
||||||
|
--status-info-bg: rgba(6, 182, 212, 0.15);
|
||||||
|
--status-neutral: #64748b;
|
||||||
|
--status-neutral-bg: rgba(100, 116, 139, 0.15);
|
||||||
|
|
||||||
|
/* Border */
|
||||||
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-glow: rgba(124, 58, 237, 0.4);
|
||||||
|
|
||||||
|
/* Glass */
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.05);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--glass-blur: 16px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-glow: 0 0 20px rgba(124, 58, 237, 0.2);
|
||||||
|
--shadow-glow-hover: 0 0 30px rgba(124, 58, 237, 0.35);
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
--transition-fast: 0.15s ease;
|
||||||
|
--transition-normal: 0.25s ease;
|
||||||
|
--transition-smooth: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Reset & Base === */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
a, .btn-link {
|
/* === Typography === */
|
||||||
color: #006bb7;
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
h1 { font-size: 1.875rem; }
|
||||||
color: #fff;
|
h2 { font-size: 1.5rem; }
|
||||||
background-color: #1b6ec2;
|
h3 { font-size: 1.25rem; }
|
||||||
border-color: #1861ac;
|
|
||||||
|
h1:focus { outline: none; }
|
||||||
|
|
||||||
|
p { line-height: 1.6; }
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
a:hover {
|
||||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
color: #22d3ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
/* === Scrollbar === */
|
||||||
padding-top: 1.1rem;
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1:focus {
|
::-webkit-scrollbar-track {
|
||||||
outline: none;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.valid.modified:not([type=checkbox]) {
|
::-webkit-scrollbar-thumb {
|
||||||
outline: 1px solid #26b050;
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invalid {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
outline: 1px solid #e50000;
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.validation-message {
|
/* === Glass Card === */
|
||||||
color: #e50000;
|
.glass-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blazor-error-boundary {
|
.glass-card:hover {
|
||||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
background: var(--bg-card-hover);
|
||||||
padding: 1rem 1rem 1rem 3.7rem;
|
border-color: var(--border-glow);
|
||||||
|
box-shadow: var(--shadow-glow-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Buttons === */
|
||||||
|
.btn-gm {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-primary {
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 12px rgba(124, 58, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-primary:hover {
|
||||||
|
background: var(--accent-gradient-hover);
|
||||||
|
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.45);
|
||||||
|
transform: translateY(-1px);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.blazor-error-boundary::after {
|
.btn-gm-primary:active {
|
||||||
content: "An error has occurred."
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-success {
|
||||||
|
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 12px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-success:hover {
|
||||||
|
box-shadow: 0 4px 20px rgba(34, 197, 94, 0.45);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-outline:hover {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-danger {
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm-danger:hover {
|
||||||
|
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-gm[disabled],
|
||||||
|
.btn-gm:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Status Badges === */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
background: var(--status-success-bg);
|
||||||
|
color: var(--status-success);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
.status-success::before { background: var(--status-success); box-shadow: 0 0 6px var(--status-success); }
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
background: var(--status-warning-bg);
|
||||||
|
color: var(--status-warning);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
}
|
||||||
|
.status-warning::before { background: var(--status-warning); box-shadow: 0 0 6px var(--status-warning); }
|
||||||
|
|
||||||
|
.status-danger {
|
||||||
|
background: var(--status-danger-bg);
|
||||||
|
color: var(--status-danger);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
.status-danger::before { background: var(--status-danger); box-shadow: 0 0 6px var(--status-danger); }
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background: var(--status-info-bg);
|
||||||
|
color: var(--status-info);
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.25);
|
||||||
|
}
|
||||||
|
.status-info::before { background: var(--status-info); box-shadow: 0 0 6px var(--status-info); }
|
||||||
|
|
||||||
|
.status-neutral {
|
||||||
|
background: var(--status-neutral-bg);
|
||||||
|
color: var(--status-neutral);
|
||||||
|
border: 1px solid rgba(100, 116, 139, 0.25);
|
||||||
|
}
|
||||||
|
.status-neutral::before { background: var(--status-neutral); }
|
||||||
|
|
||||||
|
/* === Form Controls === */
|
||||||
|
.gm-form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-form-control:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-form-control::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-form-hint {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Blazor InputText styling */
|
||||||
|
.form-control,
|
||||||
|
input[type="text"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="number"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
background: var(--bg-input) !important;
|
||||||
|
border: 1px solid var(--border-color) !important;
|
||||||
|
border-radius: var(--radius-sm) !important;
|
||||||
|
color: var(--text-primary) !important;
|
||||||
|
font-family: 'Inter', sans-serif !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
padding: 0.625rem 0.875rem !important;
|
||||||
|
transition: all var(--transition-normal) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--accent-primary) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color scheme for date/time pickers */
|
||||||
|
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Tables === */
|
||||||
|
.gm-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-table thead th {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-table tbody td {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-table tbody tr {
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-table tbody tr:hover {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Alerts === */
|
||||||
|
.gm-alert {
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-alert-info {
|
||||||
|
background: var(--status-info-bg);
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||||
|
color: var(--status-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-alert-danger {
|
||||||
|
background: var(--status-danger-bg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--status-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-alert-success {
|
||||||
|
background: var(--status-success-bg);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
color: var(--status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Breadcrumb === */
|
||||||
|
.gm-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-breadcrumb li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-breadcrumb li + li::before {
|
||||||
|
content: '›';
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-breadcrumb a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-breadcrumb a:hover {
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gm-breadcrumb .active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Loading Skeleton === */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--bg-surface) 25%,
|
||||||
|
rgba(255, 255, 255, 0.08) 50%,
|
||||||
|
var(--bg-surface) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
height: 160px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 1rem;
|
||||||
|
width: 60%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text-sm {
|
||||||
|
height: 0.75rem;
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Empty State === */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Page Container === */
|
||||||
|
.page-container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Grid === */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Animations === */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 15px rgba(124, 58, 237, 0.15); }
|
||||||
|
50% { box-shadow: 0 0 25px rgba(124, 58, 237, 0.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slideUp 0.5s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger children animation */
|
||||||
|
.stagger-children > * {
|
||||||
|
animation: fadeIn 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
|
||||||
|
.stagger-children > *:nth-child(2) { animation-delay: 0.1s; }
|
||||||
|
.stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
|
||||||
|
.stagger-children > *:nth-child(4) { animation-delay: 0.2s; }
|
||||||
|
.stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
|
||||||
|
.stagger-children > *:nth-child(6) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
/* === Blazor Overrides === */
|
||||||
|
.valid.modified:not([type=checkbox]) {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--status-success) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--status-danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-message {
|
||||||
|
color: var(--status-danger);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-error-boundary {
|
||||||
|
background: var(--status-danger-bg);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
color: var(--status-danger);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-error-boundary::after {
|
||||||
|
content: "Произошла ошибка при отображении этого компонента."
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bootstrap overrides for dark theme */
|
||||||
|
.form-label {
|
||||||
|
color: var(--text-secondary) !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Login page background === */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(ellipse at 30% 50%, rgba(124, 58, 237, 0.12) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 70% 50%, rgba(6, 182, 212, 0.08) 0%, transparent 50%);
|
||||||
|
animation: bg-drift 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bg-drift {
|
||||||
|
0% { transform: translate(0, 0) rotate(0deg); }
|
||||||
|
100% { transform: translate(-3%, 2%) rotate(3deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
animation: slideUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Mobile Sessions Cards (instead of table) === */
|
||||||
|
.session-card-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.session-table-desktop {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.darker-border-checkbox.form-check-input {
|
.session-card-mobile {
|
||||||
border-color: #929292;
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card:hover {
|
||||||
|
border-color: var(--border-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
/* === 404 / Error Pages === */
|
||||||
color: var(--bs-secondary-color);
|
.error-page {
|
||||||
text-align: end;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
.error-page-icon {
|
||||||
text-align: start;
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-page-text {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive fine-tuning === */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using GmRelay.Bot.Features.Confirmation.HandleRsvp;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Confirmation.HandleRsvp;
|
||||||
|
|
||||||
|
public sealed class RsvpFlowRulesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldRevertAndAlert_WhenConfirmedSessionGetsDecline()
|
||||||
|
{
|
||||||
|
var decision = RsvpFlowRules.Evaluate(
|
||||||
|
RsvpStatus.Declined,
|
||||||
|
SessionStatus.Confirmed,
|
||||||
|
totalParticipants: 3,
|
||||||
|
confirmedParticipants: 2);
|
||||||
|
|
||||||
|
Assert.True(decision.ShouldAlertGm);
|
||||||
|
Assert.True(decision.ShouldRevertSessionToConfirmationSent);
|
||||||
|
Assert.False(decision.ShouldMarkSessionConfirmed);
|
||||||
|
Assert.Equal("Вы отказались от участия.", decision.CallbackText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldMarkConfirmed_WhenLastParticipantConfirms()
|
||||||
|
{
|
||||||
|
var decision = RsvpFlowRules.Evaluate(
|
||||||
|
RsvpStatus.Confirmed,
|
||||||
|
SessionStatus.ConfirmationSent,
|
||||||
|
totalParticipants: 3,
|
||||||
|
confirmedParticipants: 3);
|
||||||
|
|
||||||
|
Assert.True(decision.ShouldMarkSessionConfirmed);
|
||||||
|
Assert.True(decision.ShouldNotifyGroup);
|
||||||
|
Assert.True(decision.ShouldNotifyGm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldKeepWaiting_WhenNotEveryoneConfirmed()
|
||||||
|
{
|
||||||
|
var decision = RsvpFlowRules.Evaluate(
|
||||||
|
RsvpStatus.Confirmed,
|
||||||
|
SessionStatus.ConfirmationSent,
|
||||||
|
totalParticipants: 4,
|
||||||
|
confirmedParticipants: 2);
|
||||||
|
|
||||||
|
Assert.False(decision.ShouldMarkSessionConfirmed);
|
||||||
|
Assert.False(decision.ShouldNotifyGroup);
|
||||||
|
Assert.False(decision.ShouldNotifyGm);
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
|
||||||
|
public sealed class NewSessionCommandParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
|
||||||
|
{
|
||||||
|
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
var text = """
|
||||||
|
/newsession
|
||||||
|
Название: Curse of Strahd
|
||||||
|
Время: 24.04.2026 19:30
|
||||||
|
Время: 01.05.2026 20:00
|
||||||
|
Ссылка: https://example.test/room
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = NewSessionCommandParser.Parse(text, nowUtc);
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Equal("Curse of Strahd", result.Title);
|
||||||
|
Assert.Equal("https://example.test/room", result.Link);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
|
||||||
|
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
|
||||||
|
],
|
||||||
|
result.ScheduledTimes);
|
||||||
|
Assert.Empty(result.PastTimeInputs);
|
||||||
|
Assert.Empty(result.InvalidTimeInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ShouldCollectPastAndInvalidTimes()
|
||||||
|
{
|
||||||
|
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
var text = """
|
||||||
|
Название: Delta Green
|
||||||
|
Время: 20.04.2026 19:30
|
||||||
|
Время: 31.04.2026 19:30
|
||||||
|
Время: 25.04.2026 18:00
|
||||||
|
Ссылка: https://example.test/dg
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = NewSessionCommandParser.Parse(text, nowUtc);
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Single(result.ScheduledTimes);
|
||||||
|
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
|
||||||
|
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
|
||||||
|
{
|
||||||
|
var text = """
|
||||||
|
/newsession
|
||||||
|
Название: Blades in the Dark
|
||||||
|
Время: 25.04.2026 19:30
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Null(result.Link);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class HandleRescheduleTimeInputHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildVotingMessage_ShouldShowApprovedAndPendingParticipants()
|
||||||
|
{
|
||||||
|
var approvedId = Guid.NewGuid();
|
||||||
|
var pendingId = Guid.NewGuid();
|
||||||
|
var currentTime = new DateTime(2026, 4, 25, 16, 30, 0, DateTimeKind.Utc);
|
||||||
|
var newTime = new DateTimeOffset(2026, 4, 26, 17, 0, 0, TimeSpan.Zero);
|
||||||
|
var participants = new List<VoteParticipantDto>
|
||||||
|
{
|
||||||
|
new(approvedId, "Alice", "alice"),
|
||||||
|
new(pendingId, "Bob", null)
|
||||||
|
};
|
||||||
|
|
||||||
|
var text = HandleRescheduleTimeInputHandler.BuildVotingMessage(
|
||||||
|
"Shadowrun",
|
||||||
|
currentTime,
|
||||||
|
newTime,
|
||||||
|
participants,
|
||||||
|
[approvedId]);
|
||||||
|
|
||||||
|
Assert.Contains("Shadowrun", text);
|
||||||
|
Assert.Contains("✅ @alice", text);
|
||||||
|
Assert.Contains("⏳ Bob", text);
|
||||||
|
Assert.Contains("Голоса: 1/2 ✅", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.RescheduleSession;
|
||||||
|
|
||||||
|
public sealed class RescheduleVoteRulesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldReject_WhenParticipantVotesNo()
|
||||||
|
{
|
||||||
|
var decision = RescheduleVoteRules.Evaluate("no", totalParticipants: 4, approvedParticipants: 3);
|
||||||
|
|
||||||
|
Assert.Equal(RescheduleVoteOutcome.Rejected, decision.Outcome);
|
||||||
|
Assert.False(decision.ShouldRescheduleSession);
|
||||||
|
Assert.Equal("Вы проголосовали против переноса.", decision.CallbackText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldApprove_WhenEveryoneVotedYes()
|
||||||
|
{
|
||||||
|
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 3, approvedParticipants: 3);
|
||||||
|
|
||||||
|
Assert.Equal(RescheduleVoteOutcome.Approved, decision.Outcome);
|
||||||
|
Assert.True(decision.ShouldRescheduleSession);
|
||||||
|
Assert.True(decision.ShouldResetParticipantRsvps);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_ShouldStayPending_WhileVotesOutstanding()
|
||||||
|
{
|
||||||
|
var decision = RescheduleVoteRules.Evaluate("yes", totalParticipants: 5, approvedParticipants: 2);
|
||||||
|
|
||||||
|
Assert.Equal(RescheduleVoteOutcome.Pending, decision.Outcome);
|
||||||
|
Assert.False(decision.ShouldRescheduleSession);
|
||||||
|
Assert.False(decision.ShouldResetParticipantRsvps);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
|
<ProjectReference Include="..\..\src\GmRelay.Bot\GmRelay.Bot.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\GmRelay.Web\GmRelay.Web.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using GmRelay.Bot.Infrastructure.Logging;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Logging;
|
||||||
|
|
||||||
|
public sealed class SecretRedactorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RedactConnectionString_ShouldMaskDatabasePassword()
|
||||||
|
{
|
||||||
|
var result = SecretRedactor.RedactConnectionString(
|
||||||
|
"Host=localhost;Port=5432;Database=gmrelay;Username=gmrelay;Password=super-secret");
|
||||||
|
|
||||||
|
Assert.Contains("Password=***", result);
|
||||||
|
Assert.DoesNotContain("super-secret", result);
|
||||||
|
Assert.Contains("Host=localhost", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RedactText_ShouldMaskKnownSecretKeys()
|
||||||
|
{
|
||||||
|
var result = SecretRedactor.RedactText(
|
||||||
|
"Password=super-secret Token=telegram-token apiKey=service-key");
|
||||||
|
|
||||||
|
Assert.DoesNotContain("super-secret", result);
|
||||||
|
Assert.DoesNotContain("telegram-token", result);
|
||||||
|
Assert.DoesNotContain("service-key", result);
|
||||||
|
Assert.Contains("Password=***", result);
|
||||||
|
Assert.Contains("Token=***", result);
|
||||||
|
Assert.Contains("apiKey=***", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using GmRelay.Bot.Infrastructure.Telegram;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Telegram.Bot.Types;
|
||||||
|
using Telegram.Bot.Types.Enums;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
||||||
|
|
||||||
|
public sealed class TelegramBotServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_ShouldStartPollingAfterLastPendingUpdate()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var updateSource = new FakeTelegramUpdateSource(cts);
|
||||||
|
var updateHandler = new FakeTelegramUpdateHandler();
|
||||||
|
var service = new TelegramBotService(
|
||||||
|
updateSource,
|
||||||
|
updateHandler,
|
||||||
|
NullLogger<TelegramBotService>.Instance);
|
||||||
|
|
||||||
|
await InvokeExecuteAsync(service, cts.Token);
|
||||||
|
|
||||||
|
Assert.Empty(updateHandler.HandledUpdates);
|
||||||
|
Assert.Collection(
|
||||||
|
updateSource.Calls,
|
||||||
|
call =>
|
||||||
|
{
|
||||||
|
Assert.Equal(-1, call.Offset);
|
||||||
|
Assert.Equal(1, call.Limit);
|
||||||
|
Assert.Null(call.Timeout);
|
||||||
|
Assert.Null(call.AllowedUpdates);
|
||||||
|
},
|
||||||
|
call =>
|
||||||
|
{
|
||||||
|
Assert.Equal(43, call.Offset);
|
||||||
|
Assert.Null(call.Limit);
|
||||||
|
Assert.Equal(30, call.Timeout);
|
||||||
|
Assert.Equal([UpdateType.Message, UpdateType.CallbackQuery], call.AllowedUpdates);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task InvokeExecuteAsync(TelegramBotService service, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var executeAsync = typeof(TelegramBotService).GetMethod(
|
||||||
|
"ExecuteAsync",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
Assert.NotNull(executeAsync);
|
||||||
|
|
||||||
|
var task = executeAsync.Invoke(service, [cancellationToken]) as Task;
|
||||||
|
Assert.NotNull(task);
|
||||||
|
|
||||||
|
await task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeTelegramUpdateHandler : ITelegramUpdateHandler
|
||||||
|
{
|
||||||
|
public List<Update> HandledUpdates { get; } = [];
|
||||||
|
|
||||||
|
public Task RouteAsync(Update update, CancellationToken ct)
|
||||||
|
{
|
||||||
|
HandledUpdates.Add(update);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeTelegramUpdateSource(CancellationTokenSource cts) : ITelegramUpdateSource
|
||||||
|
{
|
||||||
|
public List<PollCall> Calls { get; } = [];
|
||||||
|
|
||||||
|
public Task<Update[]> GetUpdatesAsync(
|
||||||
|
int offset,
|
||||||
|
int? limit = null,
|
||||||
|
int? timeout = null,
|
||||||
|
IEnumerable<UpdateType>? allowedUpdates = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Calls.Add(new PollCall(offset, limit, timeout, allowedUpdates?.ToArray()));
|
||||||
|
|
||||||
|
return Calls.Count switch
|
||||||
|
{
|
||||||
|
1 => Task.FromResult(new[] { new Update { Id = 42 } }),
|
||||||
|
2 => ReturnAndCancelAsync(),
|
||||||
|
_ => throw new InvalidOperationException("Unexpected polling call.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<Update[]> ReturnAndCancelAsync()
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
return Task.FromResult(Array.Empty<Update>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PollCall(
|
||||||
|
int Offset,
|
||||||
|
int? Limit,
|
||||||
|
int? Timeout,
|
||||||
|
UpdateType[]? AllowedUpdates);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class SessionBatchRendererTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldOrderSessionsAndSkipButtonsForClosedStatuses()
|
||||||
|
{
|
||||||
|
var firstSessionId = Guid.NewGuid();
|
||||||
|
var secondSessionId = Guid.NewGuid();
|
||||||
|
var cancelledSessionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), "Planned"),
|
||||||
|
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), "Cancelled"),
|
||||||
|
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), "RecruitmentClosed")
|
||||||
|
};
|
||||||
|
var participants = new[]
|
||||||
|
{
|
||||||
|
new ParticipantBatchDto(secondSessionId, "Alice", "alice"),
|
||||||
|
new ParticipantBatchDto(cancelledSessionId, "Bob", null)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = SessionBatchRenderer.Render("Campaign", sessions, participants);
|
||||||
|
var text = result.Text;
|
||||||
|
var buttons = result.Markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
|
|
||||||
|
var firstIndex = text.IndexOf(sessions[2].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||||
|
var secondIndex = text.IndexOf(sessions[0].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||||
|
var thirdIndex = text.IndexOf(sessions[1].ScheduledAt.FormatMoscow(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
Assert.Contains("Campaign", text);
|
||||||
|
Assert.True(firstIndex < secondIndex);
|
||||||
|
Assert.True(secondIndex < thirdIndex);
|
||||||
|
Assert.Contains("@alice", text);
|
||||||
|
Assert.Contains("Bob", text);
|
||||||
|
Assert.Single(result.Markup.InlineKeyboard);
|
||||||
|
Assert.Collection(
|
||||||
|
buttons.Select(button => button.CallbackData),
|
||||||
|
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
|
||||||
|
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace GmRelay.Bot.Tests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using GmRelay.Web.Services;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class AuthorizedSessionServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);
|
||||||
|
|
||||||
|
Assert.NotNull(sessions);
|
||||||
|
Assert.Single(sessions);
|
||||||
|
Assert.Equal("Session A", sessions[0].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUpcomingSessionsForGmAsync_ReturnsNull_WhenGroupBelongsToAnotherGm()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);
|
||||||
|
|
||||||
|
Assert.Null(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSessionForGmAsync_ReturnsSession_WhenSessionBelongsToOwnedGroup()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var session = await service.GetSessionForGmAsync(sessionId, gmId);
|
||||||
|
|
||||||
|
Assert.NotNull(session);
|
||||||
|
Assert.Equal(sessionId, session.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSessionForGmAsync_ReturnsNull_WhenSessionBelongsToAnotherGm()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var session = await service.GetSessionForGmAsync(sessionId, 1001L);
|
||||||
|
|
||||||
|
Assert.Null(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
|
Assert.False(store.UpdateCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateSessionForGmAsync_UpdatesOwnedSession()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var scheduledAt = DateTime.UtcNow.AddDays(1);
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b");
|
||||||
|
|
||||||
|
Assert.True(store.UpdateCalled);
|
||||||
|
Assert.Equal(groupId, store.LastUpdatedGroupId);
|
||||||
|
Assert.Equal(sessionId, store.LastUpdatedSessionId);
|
||||||
|
Assert.Equal("Updated", store.LastUpdatedTitle);
|
||||||
|
Assert.Equal(scheduledAt, store.LastUpdatedScheduledAt);
|
||||||
|
Assert.Equal("https://example.test/b", store.LastUpdatedJoinLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSessionStore(
|
||||||
|
IEnumerable<WebGameGroup>? groups = null,
|
||||||
|
IEnumerable<WebSession>? sessions = null) : ISessionStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Guid, WebGameGroup> groupsById = groups?.ToDictionary(group => group.Id) ?? [];
|
||||||
|
private readonly Dictionary<Guid, WebSession> sessionsById = sessions?.ToDictionary(session => session.Id) ?? [];
|
||||||
|
|
||||||
|
public bool UpdateCalled { get; private set; }
|
||||||
|
public Guid? LastUpdatedSessionId { get; private set; }
|
||||||
|
public Guid? LastUpdatedGroupId { get; private set; }
|
||||||
|
public string? LastUpdatedTitle { get; private set; }
|
||||||
|
public DateTime? LastUpdatedScheduledAt { get; private set; }
|
||||||
|
public string? LastUpdatedJoinLink { get; private set; }
|
||||||
|
|
||||||
|
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
||||||
|
Task.FromResult(groupsById.Values.Where(group => group.GmTelegramId == gmId).ToList());
|
||||||
|
|
||||||
|
public Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||||
|
{
|
||||||
|
groupsById.TryGetValue(groupId, out var group);
|
||||||
|
return Task.FromResult(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) =>
|
||||||
|
Task.FromResult(sessionsById.Values.Where(session => session.GroupId == groupId).ToList());
|
||||||
|
|
||||||
|
public Task<WebSession?> GetSessionAsync(Guid sessionId)
|
||||||
|
{
|
||||||
|
sessionsById.TryGetValue(sessionId, out var session);
|
||||||
|
return Task.FromResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
|
||||||
|
{
|
||||||
|
UpdateCalled = true;
|
||||||
|
LastUpdatedSessionId = sessionId;
|
||||||
|
LastUpdatedGroupId = groupId;
|
||||||
|
LastUpdatedTitle = title;
|
||||||
|
LastUpdatedScheduledAt = scheduledAt;
|
||||||
|
LastUpdatedJoinLink = joinLink;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using GmRelay.Web.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class TelegramAuthServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Verify_ShouldAcceptValidTelegramPayload()
|
||||||
|
{
|
||||||
|
const string botToken = "test-bot-token";
|
||||||
|
var authDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||||
|
var query = CreateQueryCollection(
|
||||||
|
botToken,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["auth_date"] = authDate,
|
||||||
|
["first_name"] = "Ada",
|
||||||
|
["id"] = "424242",
|
||||||
|
["last_name"] = "Lovelace",
|
||||||
|
["username"] = "ada"
|
||||||
|
});
|
||||||
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
|
var verified = service.Verify(query, out var telegramId, out var name);
|
||||||
|
|
||||||
|
Assert.True(verified);
|
||||||
|
Assert.Equal(424242L, telegramId);
|
||||||
|
Assert.Equal("Ada Lovelace", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_ShouldRejectTamperedHash()
|
||||||
|
{
|
||||||
|
const string botToken = "test-bot-token";
|
||||||
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["auth_date"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
||||||
|
["first_name"] = "Ada",
|
||||||
|
["id"] = "424242"
|
||||||
|
};
|
||||||
|
var query = CreateQueryCollection(botToken, values);
|
||||||
|
var invalidQuery = new QueryCollection(new Dictionary<string, StringValues>(query.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value))
|
||||||
|
{
|
||||||
|
["hash"] = "00"
|
||||||
|
});
|
||||||
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
|
var verified = service.Verify(invalidQuery, out _, out _);
|
||||||
|
|
||||||
|
Assert.False(verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_ShouldRejectExpiredPayload()
|
||||||
|
{
|
||||||
|
const string botToken = "test-bot-token";
|
||||||
|
var expiredAuthDate = DateTimeOffset.UtcNow.AddDays(-2).ToUnixTimeSeconds().ToString();
|
||||||
|
var query = CreateQueryCollection(
|
||||||
|
botToken,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["auth_date"] = expiredAuthDate,
|
||||||
|
["first_name"] = "Ada",
|
||||||
|
["id"] = "424242"
|
||||||
|
});
|
||||||
|
var service = new TelegramAuthService(CreateConfiguration(botToken));
|
||||||
|
|
||||||
|
var verified = service.Verify(query, out _, out _);
|
||||||
|
|
||||||
|
Assert.False(verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IConfiguration CreateConfiguration(string botToken) =>
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Telegram:BotToken"] = botToken
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static QueryCollection CreateQueryCollection(string botToken, Dictionary<string, string> values)
|
||||||
|
{
|
||||||
|
var hash = ComputeTelegramHash(botToken, values);
|
||||||
|
var queryValues = values.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => new StringValues(pair.Value));
|
||||||
|
queryValues["hash"] = new StringValues(hash);
|
||||||
|
return new QueryCollection(queryValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeTelegramHash(string botToken, IReadOnlyDictionary<string, string> values)
|
||||||
|
{
|
||||||
|
var dataCheckString = string.Join(
|
||||||
|
"\n",
|
||||||
|
values
|
||||||
|
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||||
|
.Select(pair => $"{pair.Key}={pair.Value}"));
|
||||||
|
var secretKey = SHA256.HashData(Encoding.UTF8.GetBytes(botToken));
|
||||||
|
var hashBytes = HMACSHA256.HashData(secretKey, Encoding.UTF8.GetBytes(dataCheckString));
|
||||||
|
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user