542f15f2d6
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler, ExportCalendarHandler, HandleRescheduleTimeInputHandler, HandleRescheduleVoteHandler to GmRelay.Shared - Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync, SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync - Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers - Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler - Update UpdateRouter with explicit type aliases for ambiguous handler names - Add contract and source-inspection tests for extracted handlers - Bump version 3.1.1 → 3.2.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
279 lines
11 KiB
C#
279 lines
11 KiB
C#
using Dapper;
|
|
using GmRelay.Bot.Features.Notifications;
|
|
using GmRelay.Shared.Domain;
|
|
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
|
using GmRelay.Shared.Platform;
|
|
using GmRelay.Shared.Rendering;
|
|
using Npgsql;
|
|
using Telegram.Bot;
|
|
using Telegram.Bot.Types;
|
|
using Telegram.Bot.Types.ReplyMarkups;
|
|
using GmRelay.Bot.Infrastructure.Telegram;
|
|
|
|
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
|
|
|
|
/// <summary>
|
|
/// Telegram adapter for reschedule time input.
|
|
/// Delegates core logic to the shared handler, then performs Telegram-specific
|
|
/// message sending, DM notifications, vote_message_id storage, and cleanup.
|
|
/// </summary>
|
|
public sealed class HandleRescheduleTimeInputHandler(
|
|
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
|
|
NpgsqlDataSource dataSource,
|
|
ITelegramBotClient bot,
|
|
IPlatformMessenger messenger,
|
|
DirectSessionNotificationSender directSender,
|
|
ILogger<HandleRescheduleTimeInputHandler> logger)
|
|
{
|
|
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
|
|
{
|
|
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
|
|
return false;
|
|
|
|
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
|
|
new PlatformUser(
|
|
PlatformKind.Telegram,
|
|
message.From.Id.ToString(),
|
|
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
|
|
message.From.Username),
|
|
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
|
|
message.Text.Trim());
|
|
|
|
var result = await sharedHandler.HandleAsync(command, ct);
|
|
if (!result.Handled)
|
|
return false;
|
|
|
|
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
|
|
{
|
|
await messenger.SendGroupMessageAsync(
|
|
command.Group,
|
|
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
|
|
ct);
|
|
return true;
|
|
}
|
|
|
|
if (result.IsRescheduledImmediately)
|
|
{
|
|
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
|
|
{
|
|
await TryUpdateBatchMessage(
|
|
command.Group,
|
|
result.UpdatedView,
|
|
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
|
|
ct);
|
|
}
|
|
|
|
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
|
|
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
|
return true;
|
|
}
|
|
|
|
// Voting mode
|
|
var voteText = BuildVotingMessage(
|
|
result.Title!,
|
|
result.CurrentScheduledAt,
|
|
result.VotingDeadlineAt!.Value,
|
|
result.Options,
|
|
result.Participants,
|
|
[]);
|
|
|
|
var keyboard = BuildVotingKeyboard(result.Options);
|
|
|
|
var voteMsg = await bot.SendMessage(
|
|
chatId: message.Chat.Id,
|
|
messageThreadId: message.MessageThreadId,
|
|
text: voteText,
|
|
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
|
replyMarkup: keyboard,
|
|
cancellationToken: ct);
|
|
|
|
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
|
|
if (mode.ShouldSendDirectMessages())
|
|
{
|
|
var optionsText = string.Join(
|
|
"\n",
|
|
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
|
|
var directText = $"""
|
|
🔄 <b>Голосование за перенос сессии</b>
|
|
|
|
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
|
|
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
|
|
🗳 Варианты:
|
|
{optionsText}
|
|
|
|
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
|
|
|
|
Проголосуйте кнопкой в групповом сообщении.
|
|
""";
|
|
|
|
await directSender.SendAsync(
|
|
result.Participants.Select(p => new DirectNotificationRecipient(
|
|
p.TelegramId,
|
|
p.DisplayName)),
|
|
directText,
|
|
"reschedule-vote",
|
|
result.ProposalId.Value,
|
|
ct);
|
|
}
|
|
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
await connection.ExecuteAsync(
|
|
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
|
|
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
|
|
|
|
logger.LogInformation(
|
|
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
|
|
result.ProposalId.Value,
|
|
result.ProposalId.Value,
|
|
result.Options.Count,
|
|
result.VotingDeadlineAt.Value);
|
|
|
|
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
|
|
return true;
|
|
}
|
|
|
|
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
|
|
{
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
|
|
"""
|
|
SELECT s.notification_mode
|
|
FROM sessions s
|
|
JOIN reschedule_proposals rp ON rp.session_id = s.id
|
|
WHERE rp.id = @Id
|
|
""",
|
|
new { Id = proposalId });
|
|
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
|
|
}
|
|
|
|
private async Task TryUpdateBatchMessage(
|
|
PlatformGroup group,
|
|
SessionBatchViewModel view,
|
|
PlatformMessageRef scheduleMessage,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await messenger.UpdateScheduleAsync(
|
|
new PlatformScheduleMessage(group, view, scheduleMessage),
|
|
ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
|
|
}
|
|
}
|
|
|
|
internal static string BuildVotingMessage(
|
|
string title,
|
|
DateTime currentTime,
|
|
DateTimeOffset deadline,
|
|
IReadOnlyList<RescheduleOptionDto> options,
|
|
IReadOnlyList<VoteParticipantDto> participants,
|
|
IReadOnlyList<RescheduleOptionVoteDto> votes)
|
|
{
|
|
var votesByOption = votes
|
|
.GroupBy(v => v.OptionId)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet();
|
|
var pendingParticipants = participants
|
|
.Where(p => !votedPlayerIds.Contains(p.PlayerId))
|
|
.Select(FormatParticipantName)
|
|
.ToList();
|
|
|
|
var lines = new List<string>
|
|
{
|
|
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
|
|
"",
|
|
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
|
|
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
|
|
"",
|
|
"Выберите один из вариантов:"
|
|
};
|
|
|
|
foreach (var option in options.OrderBy(x => x.DisplayOrder))
|
|
{
|
|
var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []);
|
|
lines.Add(
|
|
$"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК) — {FormatVoteCount(optionVotes.Count)}");
|
|
|
|
if (optionVotes.Count > 0)
|
|
{
|
|
lines.Add($" {string.Join(", ", optionVotes.Select(FormatParticipantName))}");
|
|
}
|
|
}
|
|
|
|
if (pendingParticipants.Count > 0)
|
|
{
|
|
lines.Add("");
|
|
lines.Add($"Не проголосовали: {string.Join(", ", pendingParticipants)}");
|
|
}
|
|
|
|
lines.Add("");
|
|
lines.Add($"Голосов: {votedPlayerIds.Count}/{participants.Count}");
|
|
lines.Add("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется.");
|
|
|
|
return string.Join("\n", lines);
|
|
}
|
|
|
|
internal static InlineKeyboardMarkup BuildVotingKeyboard(IReadOnlyList<RescheduleOptionDto> options)
|
|
{
|
|
return new InlineKeyboardMarkup(
|
|
options
|
|
.OrderBy(option => option.DisplayOrder)
|
|
.Select(option => new[]
|
|
{
|
|
InlineKeyboardButton.WithCallbackData(
|
|
$"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}",
|
|
$"reschedule_vote:{option.OptionId}")
|
|
}));
|
|
}
|
|
|
|
internal static string FormatParticipantName(VoteParticipantDto participant)
|
|
{
|
|
return participant.TelegramUsername is { Length: > 0 } username
|
|
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
|
: System.Net.WebUtility.HtmlEncode(participant.DisplayName);
|
|
}
|
|
|
|
internal static string FormatParticipantName(RescheduleOptionVoteDto vote)
|
|
{
|
|
return vote.TelegramUsername is { Length: > 0 } username
|
|
? $"@{System.Net.WebUtility.HtmlEncode(username)}"
|
|
: System.Net.WebUtility.HtmlEncode(vote.DisplayName);
|
|
}
|
|
|
|
private static string FormatVoteCount(int count)
|
|
{
|
|
var modulo100 = count % 100;
|
|
var modulo10 = count % 10;
|
|
var word = modulo100 is >= 11 and <= 14
|
|
? "голосов"
|
|
: modulo10 switch
|
|
{
|
|
1 => "голос",
|
|
>= 2 and <= 4 => "голоса",
|
|
_ => "голосов"
|
|
};
|
|
|
|
return $"{count} {word}";
|
|
}
|
|
|
|
private static string FormatButtonTime(DateTimeOffset utc)
|
|
=> utc.ToOffset(TimeSpan.FromHours(3)).ToString(
|
|
"dd.MM HH:mm",
|
|
System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await bot.DeleteMessage(chatId, messageId, cancellationToken: ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to delete message {MessageId} in chat {ChatId}", messageId, chatId);
|
|
}
|
|
}
|
|
}
|