test: cover core bot and web scenarios
This commit is contained in:
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.1.2
|
VERSION: 1.1.3
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.1.2</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.1.2
|
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.1.2
|
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;
|
|
||||||
string? link = null;
|
|
||||||
var scheduledTimes = new List<DateTimeOffset>();
|
|
||||||
|
|
||||||
foreach (var line in text.Split('\n'))
|
foreach (var timeInput in parseResult.PastTimeInputs)
|
||||||
{
|
{
|
||||||
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,10 +40,12 @@ 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;
|
||||||
var chatTitle = message.Chat.Title ?? "Private Chat";
|
var chatTitle = message.Chat.Title ?? "Private Chat";
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,27 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
Assert.Equal(sessionId, session.Id);
|
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]
|
[Fact]
|
||||||
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
|
public async Task UpdateSessionForGmAsync_Throws_WhenSessionBelongsToAnotherGm()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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