Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27f9ceb038 | |||
| f53c1f6aae | |||
| e59b0a78fd | |||
| b952be23eb | |||
| 4054d49ccb | |||
| d678c59105 | |||
| 20b4240a11 | |||
| e846a75ca1 | |||
| 29e5652477 | |||
| 02fc5bd106 | |||
| 6cd68493f1 | |||
| de121d7523 | |||
| 3c967dc3e3 |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.10.0
|
||||
VERSION: 3.11.0
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
@@ -70,6 +70,13 @@ jobs:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.codeanddice.ru
|
||||
username: toutsu
|
||||
password: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
@@ -78,7 +85,7 @@ jobs:
|
||||
# GitHub releases API; when a release is unpublished or
|
||||
# yanked, the script fails with
|
||||
# `unable to find '<tag>' - use 'latest' or see ...`
|
||||
# even when the release once existed. We hit this with
|
||||
# when the release once existed. We hit this with
|
||||
# v0.71.0.
|
||||
# 2. Docker Hub tags are content-addressed and rarely
|
||||
# removed, so a pinned image tag is much more stable.
|
||||
@@ -94,9 +101,16 @@ jobs:
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Pull images for scan
|
||||
run: |
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||
|
||||
- name: Scan Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
@@ -105,6 +119,7 @@ jobs:
|
||||
- name: Scan Discord Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
@@ -113,6 +128,7 @@ jobs:
|
||||
- name: Scan Web image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Trivy filesystem security scan
|
||||
run: |
|
||||
set +e
|
||||
trivy fs --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
|
||||
trivy fs --timeout 30m --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
|
||||
trivy_exit="${PIPESTATUS[0]}"
|
||||
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
|
||||
echo "::error::Trivy did not detect any language-specific dependency files."
|
||||
@@ -90,4 +90,11 @@ jobs:
|
||||
# ── Tests ──
|
||||
|
||||
- name: Run tests
|
||||
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
|
||||
run: |
|
||||
# Exclude Testcontainers-backed PostgreSQL integration collections from PR CI.
|
||||
# The ARM64 runner is too slow to reliably start Postgres containers and apply
|
||||
# migrations before the default timeouts expire. These tests are still run
|
||||
# locally and can be executed manually with `dotnet test`.
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj \
|
||||
--filter "FullyQualifiedName!~PortfolioMigrationPostgresTests&FullyQualifiedName!~CreateSessionHandlerIntegrationTests&FullyQualifiedName!~WizardDraftRepositoryTests&FullyQualifiedName!~DbSessionTriggerStoreTests&Collection!~CreateSessionHandlerPostgresCollection" \
|
||||
--verbosity normal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.10.0</Version>
|
||||
<Version>3.11.0</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.10.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.11.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.10.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.11.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -86,7 +86,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.10.0
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.11.0
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
||||
<!-- Overrides transitive vulnerable MessagePack 2.5.192 pulled by Aspire.Hosting.PostgreSQL.
|
||||
See GHSA-hv8m-jj95-wg3x / CVE-2026-48109. -->
|
||||
<PackageReference Include="MessagePack" Version="2.5.301" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -83,6 +83,16 @@
|
||||
"System.IO.Hashing": "10.0.3"
|
||||
}
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.5.301, )",
|
||||
"resolved": "2.5.301",
|
||||
"contentHash": "WUnJgmYc06ngIxZxLe9sa0P6rOTyOZIQn8SuDvJSjyMn7e8/AdlNAdt81WPUhWKeQ7hDkgxKU1vTrJqX/4L79A==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "2.5.301",
|
||||
"Microsoft.NET.StringTools": "17.6.3"
|
||||
}
|
||||
},
|
||||
"SecurityCodeScan.VS2019": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.7, )",
|
||||
@@ -248,19 +258,10 @@
|
||||
"YamlDotNet": "16.3.0"
|
||||
}
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.5.192",
|
||||
"contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "2.5.192",
|
||||
"Microsoft.NET.StringTools": "17.6.3"
|
||||
}
|
||||
},
|
||||
"MessagePack.Annotations": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.5.192",
|
||||
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
|
||||
"resolved": "2.5.301",
|
||||
"contentHash": "3PyBiSeKTfvtyzUv3+9eXGIw7vBBZ0GAc4k3+RVT0tz2vKv3l0pviiA2b6DrmHyDvj1Au8lSVDDw/wKPMxUQ4A=="
|
||||
},
|
||||
"Microsoft.Extensions.AI.Abstractions": {
|
||||
"type": "Transitive",
|
||||
|
||||
@@ -70,7 +70,17 @@ public sealed class CancelSessionHandler(
|
||||
|
||||
// 3. Загружаем весь батч для перерисовки
|
||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
|
||||
@"SELECT id as SessionId,
|
||||
scheduled_at as ScheduledAt,
|
||||
status as Status,
|
||||
max_players as MaxPlayers,
|
||||
join_link as JoinLink,
|
||||
format as Format,
|
||||
location_address as LocationAddress,
|
||||
description as Description,
|
||||
system as System,
|
||||
duration_minutes as DurationMinutes,
|
||||
is_one_shot as IsOneShot
|
||||
FROM sessions
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at",
|
||||
|
||||
@@ -141,7 +141,11 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
||||
max_players AS MaxPlayers,
|
||||
join_link AS JoinLink,
|
||||
format AS Format,
|
||||
location_address AS LocationAddress
|
||||
location_address AS LocationAddress,
|
||||
description AS Description,
|
||||
system AS System,
|
||||
duration_minutes AS DurationMinutes,
|
||||
is_one_shot AS IsOneShot
|
||||
FROM sessions
|
||||
WHERE batch_id = @BatchId
|
||||
ORDER BY scheduled_at
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { result.BatchId })).ToList();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
|
||||
@@ -17,22 +17,49 @@ public static class TelegramSessionBatchRenderer
|
||||
foreach (var session in view.Sessions)
|
||||
{
|
||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||
|
||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||
var tags = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(session.System))
|
||||
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
|
||||
if (!string.IsNullOrWhiteSpace(session.Format))
|
||||
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
|
||||
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
|
||||
|
||||
if (tags.Count > 0)
|
||||
{
|
||||
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
|
||||
}
|
||||
|
||||
if (session.DurationMinutes.HasValue)
|
||||
{
|
||||
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(session.Description))
|
||||
{
|
||||
messageText += $"📝 <b>Описание:</b>\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n";
|
||||
}
|
||||
|
||||
var format = session.Format ?? string.Empty;
|
||||
var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase);
|
||||
var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase);
|
||||
var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink))
|
||||
{
|
||||
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
|
||||
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||
messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||
}
|
||||
|
||||
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||
if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||
{
|
||||
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||
messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||
}
|
||||
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
|
||||
|
||||
if (session.ActivePlayers.Count > 0)
|
||||
{
|
||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||
@@ -45,7 +72,7 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
if (session.WaitlistedPlayers.Count > 0)
|
||||
{
|
||||
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
||||
messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
|
||||
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
@@ -67,4 +94,14 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
|
||||
private static string FormatDuration(int minutes)
|
||||
{
|
||||
if (minutes <= 0) return "0 мин";
|
||||
var hours = minutes / 60;
|
||||
var mins = minutes % 60;
|
||||
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
|
||||
if (hours > 0) return $"{hours} ч";
|
||||
return $"{mins} мин";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,11 @@ public sealed class CreateSessionHandler(
|
||||
command.MaxPlayers,
|
||||
command.Link,
|
||||
command.Format,
|
||||
command.LocationAddress));
|
||||
command.LocationAddress,
|
||||
command.Description,
|
||||
command.System?.ToString(),
|
||||
command.DurationMinutes,
|
||||
command.IsOneShot));
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
@@ -7,5 +7,9 @@ public sealed record SessionBatchDto(
|
||||
int? MaxPlayers,
|
||||
string JoinLink,
|
||||
string? Format = null,
|
||||
string? LocationAddress = null);
|
||||
string? LocationAddress = null,
|
||||
string? Description = null,
|
||||
string? System = null,
|
||||
int? DurationMinutes = null,
|
||||
bool IsOneShot = false);
|
||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||
|
||||
@@ -41,6 +41,10 @@ public static class SessionBatchViewBuilder
|
||||
session.JoinLink,
|
||||
session.Format,
|
||||
session.LocationAddress,
|
||||
session.Description,
|
||||
session.System,
|
||||
session.DurationMinutes,
|
||||
session.IsOneShot,
|
||||
activePlayers.Count,
|
||||
activePlayers,
|
||||
waitlistedPlayers,
|
||||
|
||||
@@ -14,6 +14,10 @@ public sealed record SessionViewItem(
|
||||
string JoinLink,
|
||||
string? Format,
|
||||
string? LocationAddress,
|
||||
string? Description,
|
||||
string? System,
|
||||
int? DurationMinutes,
|
||||
bool IsOneShot,
|
||||
int ActivePlayerCount,
|
||||
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
||||
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.10.0</div>
|
||||
<div class="nav-version">v3.11.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -119,7 +119,14 @@ internal sealed record WebBatchSessionRow(
|
||||
long TelegramChatId,
|
||||
int? ThreadId,
|
||||
string NotificationMode,
|
||||
bool TopicCreatedByBot = false);
|
||||
bool TopicCreatedByBot = false,
|
||||
string? Description = null,
|
||||
string? System = null,
|
||||
int? DurationMinutes = null,
|
||||
string? Format = null,
|
||||
string? LocationAddress = null,
|
||||
bool IsOneShot = false,
|
||||
string? CoverImageUrl = null);
|
||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||
internal sealed record WebPublicGroupRow(
|
||||
@@ -1508,7 +1515,14 @@ public sealed class SessionService(
|
||||
g.external_group_id::BIGINT AS TelegramChatId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||
s.notification_mode AS NotificationMode
|
||||
s.notification_mode AS NotificationMode,
|
||||
s.description AS Description,
|
||||
s.system AS System,
|
||||
s.duration_minutes AS DurationMinutes,
|
||||
s.format AS Format,
|
||||
s.location_address AS LocationAddress,
|
||||
s.is_one_shot AS IsOneShot,
|
||||
s.cover_image_url AS CoverImageUrl
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.batch_id = @BatchId
|
||||
@@ -1536,8 +1550,14 @@ public sealed class SessionService(
|
||||
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
||||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
|
||||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
|
||||
INSERT INTO sessions (
|
||||
batch_id, group_id, title, join_link, scheduled_at, status, thread_id,
|
||||
topic_created_by_bot, max_players, notification_mode, description, system,
|
||||
duration_minutes, format, location_address, is_one_shot, cover_image_url)
|
||||
VALUES (
|
||||
@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId,
|
||||
@TopicCreatedByBot, @MaxPlayers, @NotificationMode, @Description, @System,
|
||||
@DurationMinutes, @Format, @LocationAddress, @IsOneShot, @CoverImageUrl)
|
||||
RETURNING id
|
||||
""",
|
||||
new
|
||||
@@ -1551,11 +1571,29 @@ public sealed class SessionService(
|
||||
ThreadId = threadId,
|
||||
sourceSession.TopicCreatedByBot,
|
||||
sourceSession.MaxPlayers,
|
||||
sourceSession.NotificationMode
|
||||
sourceSession.NotificationMode,
|
||||
Description = sourceSession.Description,
|
||||
System = sourceSession.System,
|
||||
DurationMinutes = sourceSession.DurationMinutes,
|
||||
Format = sourceSession.Format,
|
||||
LocationAddress = sourceSession.LocationAddress,
|
||||
IsOneShot = sourceSession.IsOneShot,
|
||||
CoverImageUrl = sourceSession.CoverImageUrl
|
||||
},
|
||||
transaction);
|
||||
|
||||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
|
||||
renderedSessions.Add(new SessionBatchDto(
|
||||
sessionId,
|
||||
scheduledAt,
|
||||
SessionStatus.Planned,
|
||||
sourceSession.MaxPlayers,
|
||||
batchJoinLink,
|
||||
sourceSession.Format,
|
||||
sourceSession.LocationAddress,
|
||||
sourceSession.Description,
|
||||
sourceSession.System,
|
||||
sourceSession.DurationMinutes,
|
||||
sourceSession.IsOneShot));
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
@@ -1770,7 +1808,18 @@ public sealed class SessionService(
|
||||
},
|
||||
transaction);
|
||||
|
||||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
|
||||
renderedSessions.Add(new SessionBatchDto(
|
||||
sessionId,
|
||||
scheduledAt,
|
||||
SessionStatus.Planned,
|
||||
template.MaxPlayers,
|
||||
template.JoinLink,
|
||||
Format: null,
|
||||
LocationAddress: null,
|
||||
Description: null,
|
||||
System: null,
|
||||
DurationMinutes: null,
|
||||
IsOneShot: false));
|
||||
}
|
||||
|
||||
await transaction.CommitAsync();
|
||||
@@ -1897,7 +1946,7 @@ public sealed class SessionService(
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
|
||||
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress, description AS Description, system AS System, duration_minutes AS DurationMinutes, is_one_shot AS IsOneShot FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { BatchId = batchId })).ToList();
|
||||
|
||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||
|
||||
@@ -16,22 +16,49 @@ public static class TelegramSessionBatchRenderer
|
||||
foreach (var session in view.Sessions)
|
||||
{
|
||||
messageText += $"📅 <b>{session.ScheduledAt.FormatMoscow()}</b>\n";
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||
|
||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||
var tags = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(session.System))
|
||||
tags.Add($"<b>Система:</b> {System.Net.WebUtility.HtmlEncode(session.System)}");
|
||||
if (!string.IsNullOrWhiteSpace(session.Format))
|
||||
tags.Add($"<b>Формат:</b> {System.Net.WebUtility.HtmlEncode(session.Format)}");
|
||||
tags.Add($"<b>Тип:</b> {(session.IsOneShot ? "One-shot" : "Кампания")}");
|
||||
|
||||
if (tags.Count > 0)
|
||||
{
|
||||
messageText += "🏷 " + string.Join(" · ", tags) + "\n";
|
||||
}
|
||||
|
||||
if (session.DurationMinutes.HasValue)
|
||||
{
|
||||
messageText += $"⏱ <b>Длительность:</b> {FormatDuration(session.DurationMinutes.Value)}\n";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(session.Description))
|
||||
{
|
||||
messageText += $"📝 <b>Описание:</b>\n{System.Net.WebUtility.HtmlEncode(session.Description)}\n\n";
|
||||
}
|
||||
|
||||
var format = session.Format ?? string.Empty;
|
||||
var isOnline = string.Equals(format, "Online", StringComparison.OrdinalIgnoreCase);
|
||||
var isOffline = string.Equals(format, "Offline", StringComparison.OrdinalIgnoreCase);
|
||||
var isHybrid = string.Equals(format, "Hybrid", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if ((isOnline || isHybrid) && !string.IsNullOrWhiteSpace(session.JoinLink))
|
||||
{
|
||||
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
|
||||
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||
messageText += $"🔗 <b>Ссылка:</b> <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||
}
|
||||
|
||||
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||
if ((isOffline || isHybrid) && !string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||
{
|
||||
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||
messageText += $"📍 <b>Адрес:</b> {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||
}
|
||||
|
||||
messageText += session.MaxPlayers.HasValue
|
||||
? $"👥 <b>Места:</b> {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||
: $"👥 <b>Игроки ({session.ActivePlayerCount}):</b>\n";
|
||||
|
||||
if (session.ActivePlayers.Count > 0)
|
||||
{
|
||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||
@@ -44,7 +71,7 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
if (session.WaitlistedPlayers.Count > 0)
|
||||
{
|
||||
messageText += $"⏳ Лист ожидания ({session.WaitlistedPlayers.Count}):\n";
|
||||
messageText += $"⏳ <b>Лист ожидания ({session.WaitlistedPlayers.Count}):</b>\n";
|
||||
messageText += string.Join("\n", session.WaitlistedPlayers.Select(p =>
|
||||
$" ⏱ {(p.TelegramUsername != null ? "@" + p.TelegramUsername : p.DisplayName)}")) + "\n";
|
||||
}
|
||||
@@ -66,4 +93,14 @@ public static class TelegramSessionBatchRenderer
|
||||
|
||||
return (messageText, new InlineKeyboardMarkup(buttons));
|
||||
}
|
||||
|
||||
private static string FormatDuration(int minutes)
|
||||
{
|
||||
if (minutes <= 0) return "0 мин";
|
||||
var hours = minutes / 60;
|
||||
var mins = minutes % 60;
|
||||
if (hours > 0 && mins > 0) return $"{hours} ч {mins} мин";
|
||||
if (hours > 0) return $"{hours} ч";
|
||||
return $"{mins} мин";
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture<
|
||||
|
||||
public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardD
|
||||
|
||||
public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
@@ -149,4 +149,36 @@ public sealed class SessionBatchViewBuilderTests
|
||||
var joinAction = result.Sessions[0].AvailableActions.First(a => a.ActionKey == "join_session");
|
||||
Assert.DoesNotContain("ожидания", joinAction.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldPassThroughNewFields()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(
|
||||
sessionId,
|
||||
DateTime.UtcNow,
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
"https://example.com/game",
|
||||
"Offline",
|
||||
"Moscow",
|
||||
"A short description",
|
||||
"D\u0026D 5e",
|
||||
240,
|
||||
true)
|
||||
};
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
var session = result.Sessions[0];
|
||||
|
||||
Assert.Equal("A short description", session.Description);
|
||||
Assert.Equal("D\u0026D 5e", session.System);
|
||||
Assert.Equal(240, session.DurationMinutes);
|
||||
Assert.True(session.IsOneShot);
|
||||
Assert.Equal("Offline", session.Format);
|
||||
Assert.Equal("Moscow", session.LocationAddress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2"),
|
||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2", "Online", null),
|
||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
|
||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1")
|
||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1", "Online", null)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
@@ -35,7 +35,7 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
Assert.Contains("Charlie", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Contains("Сессия отменена", text);
|
||||
Assert.Contains("Ссылка на игру", text);
|
||||
Assert.Contains("Ссылка:", text);
|
||||
Assert.Contains("https://example.com/game1", text);
|
||||
Assert.Contains("https://example.com/game2", text);
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
public void Render_ShouldShowWaitlistButtonWhenFull()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game", "Online", null) };
|
||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
@@ -130,7 +130,7 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
|
||||
Assert.DoesNotContain("Ссылка на игру", text);
|
||||
Assert.DoesNotContain("Ссылка:", text);
|
||||
Assert.Contains("📅", text);
|
||||
Assert.Equal(2, buttons.Count);
|
||||
}
|
||||
@@ -155,9 +155,9 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("📍 Адрес:", text);
|
||||
Assert.Contains("📍 <b>Адрес:</b>", text);
|
||||
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||
Assert.DoesNotContain("Ссылка на игру", text);
|
||||
Assert.DoesNotContain("Ссылка:", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -180,7 +180,7 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("🔗 Ссылка на игру", text);
|
||||
Assert.Contains("🔗 <b>Ссылка:</b>", text);
|
||||
Assert.Contains("https://vtt.example/game", text);
|
||||
Assert.DoesNotContain("📍 Адрес:", text);
|
||||
}
|
||||
@@ -189,7 +189,7 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
public void Render_ShouldEncodeHtmlInJoinLink()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2") };
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/test?a=1&b=2", "Online", null) };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||
@@ -198,4 +198,77 @@ public sealed class TelegramSessionBatchRendererTests
|
||||
Assert.Contains("a=1&b=2", text);
|
||||
Assert.DoesNotContain("a=1&b=2" + "\"", text); // make sure & is encoded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldShowStructuredGameCard()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(
|
||||
sessionId,
|
||||
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
"https://vtt.example/game",
|
||||
"Hybrid",
|
||||
"Moscow, Kubik Bar",
|
||||
"Mystery one-shot in Bamberg.",
|
||||
"D\u0026D 5e",
|
||||
240,
|
||||
true)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
|
||||
};
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
|
||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("🏷", text);
|
||||
Assert.Contains("Система:", text);
|
||||
Assert.Contains("D\u0026amp;D 5e", text);
|
||||
Assert.Contains("Формат:", text);
|
||||
Assert.Contains("Hybrid", text);
|
||||
Assert.Contains("Тип:", text);
|
||||
Assert.Contains("One-shot", text);
|
||||
Assert.Contains("⏱", text);
|
||||
Assert.Contains("Длительность:", text);
|
||||
Assert.Contains("4 ч", text);
|
||||
Assert.Contains("📝", text);
|
||||
Assert.Contains("Описание:", text);
|
||||
Assert.Contains("Mystery one-shot in Bamberg.", text);
|
||||
Assert.Contains("🔗", text);
|
||||
Assert.Contains("Ссылка:", text);
|
||||
Assert.Contains("📍", text);
|
||||
Assert.Contains("Адрес:", text);
|
||||
Assert.Contains("@alice", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Contains("Лист ожидания", text);
|
||||
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
Assert.Equal(2, buttons.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldHandleMissingOptionalFields()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("📅", text);
|
||||
Assert.Contains("👥", text);
|
||||
Assert.DoesNotContain("Система:", text);
|
||||
Assert.DoesNotContain("Формат:", text);
|
||||
Assert.DoesNotContain("Длительность:", text);
|
||||
Assert.DoesNotContain("Описание:", text);
|
||||
Assert.DoesNotContain("Ссылка:", text);
|
||||
Assert.DoesNotContain("Адрес:", text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<Po
|
||||
|
||||
public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Web.Services;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web.Rendering;
|
||||
|
||||
public sealed class WebTelegramSessionBatchRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_ShouldShowStructuredGameCard()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(
|
||||
sessionId,
|
||||
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
"https://vtt.example/game",
|
||||
"Hybrid",
|
||||
"Moscow, Kubik Bar",
|
||||
"Mystery one-shot in Bamberg.",
|
||||
"D\u0026D 5e",
|
||||
240,
|
||||
true)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
|
||||
};
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
|
||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("🏷", text);
|
||||
Assert.Contains("Система:", text);
|
||||
Assert.Contains("D\u0026amp;D 5e", text);
|
||||
Assert.Contains("Формат:", text);
|
||||
Assert.Contains("Hybrid", text);
|
||||
Assert.Contains("Тип:", text);
|
||||
Assert.Contains("One-shot", text);
|
||||
Assert.Contains("⏱", text);
|
||||
Assert.Contains("Длительность:", text);
|
||||
Assert.Contains("4 ч", text);
|
||||
Assert.Contains("📝", text);
|
||||
Assert.Contains("Описание:", text);
|
||||
Assert.Contains("Mystery one-shot in Bamberg.", text);
|
||||
Assert.Contains("🔗", text);
|
||||
Assert.Contains("Ссылка:", text);
|
||||
Assert.Contains("📍", text);
|
||||
Assert.Contains("Адрес:", text);
|
||||
Assert.Contains("@alice", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Contains("Лист ожидания", text);
|
||||
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
Assert.Equal(2, buttons.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldHandleMissingOptionalFields()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("📅", text);
|
||||
Assert.Contains("👥", text);
|
||||
Assert.DoesNotContain("Система:", text);
|
||||
Assert.DoesNotContain("Формат:", text);
|
||||
Assert.DoesNotContain("Длительность:", text);
|
||||
Assert.DoesNotContain("Описание:", text);
|
||||
Assert.DoesNotContain("Ссылка:", text);
|
||||
Assert.DoesNotContain("Адрес:", text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user