2 Commits

Author SHA1 Message Date
Toutsu 675ac1226e chore: make compose config portable
Deploy Telegram Bot / build-and-push (push) Successful in 40s
Deploy Telegram Bot / deploy (push) Successful in 17s
2026-04-24 10:44:33 +03:00
Toutsu b80002aa36 refactor: unify session status model
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / deploy (push) Successful in 19s
Fixes #5
2026-04-24 10:26:45 +03:00
18 changed files with 153 additions and 53 deletions
+3
View File
@@ -8,3 +8,6 @@ TELEGRAM_BOT_USERNAME=YOUR_BOT_USERNAME_HERE
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase POSTGRES_PASSWORD=StrongPasswordForDatabase
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.1.3 VERSION: 1.1.5
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.1.3</Version> <Version>1.1.5</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+7 -3
View File
@@ -65,6 +65,9 @@ TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль POSTGRES_PASSWORD=ваш_надежный_пароль
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
``` ```
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте. *(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
@@ -72,12 +75,13 @@ POSTGRES_PASSWORD=ваш_надежный_пароль
### 3. Запуск ### 3. Запуск
Выполните команду: Выполните команду:
```bash ```bash
docker compose up -d -build docker compose up -d
``` ```
Инфраструктура автоматически: Инфраструктура автоматически:
- Поднимет PostgreSQL. - Создаст локальную Docker-сеть и volume PostgreSQL, если их ещё нет.
- Поднимет PostgreSQL, доступный для контейнеров как `db:5432`.
- Запустит бота (применив миграции БД). - Запустит бота (применив миграции БД).
- Запустит веб-интерфейс (доступен по умолчанию на порту **8080** внутри контейнера). - Запустит веб-интерфейс на `http://localhost:8080` или другом порту из `GMRELAY_WEB_PORT`.
--- ---
+22 -18
View File
@@ -1,16 +1,15 @@
services: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: gmrelay_db
restart: always restart: always
environment: environment:
POSTGRES_USER: gmrelay POSTGRES_USER: gmrelay
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: gmrelay_db POSTGRES_DB: gmrelay_db
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports: networks:
- "5432:5432" - gmrelay
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ] test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ]
interval: 3s interval: 3s
@@ -18,35 +17,40 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.3 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.5
container_name: gmrelay_bot
restart: always restart: always
network_mode: host
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}" - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
networks:
- gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.3 image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.5
container_name: gmrelay_web
restart: always restart: always
network_mode: host
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}" - "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME}" - "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes: volumes:
- web_keys:/app/dataprotection-keys - web_keys:/app/dataprotection-keys
networks:
- gmrelay
volumes: volumes:
pgdata: pgdata:
external: true name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
name: game_pgdata
web_keys: web_keys:
name: gmrelay_web_keys name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
networks:
gmrelay:
driver: bridge
@@ -48,7 +48,10 @@ public sealed class CancelSessionHandler(
} }
// 2. Отменяем сессию // 2. Отменяем сессию
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction); await connection.ExecuteAsync(
"UPDATE sessions SET status = @Status WHERE id = @Id",
new { Id = command.SessionId, Status = SessionStatus.Cancelled },
transaction);
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@@ -1,4 +1,5 @@
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;
@@ -93,7 +94,7 @@ public sealed class CreateSessionHandler(
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) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId) VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId)
RETURNING id; RETURNING id;
""", """,
new new
@@ -103,11 +104,12 @@ public sealed class CreateSessionHandler(
Title = title, Title = title,
Link = link, Link = link,
ScheduledAt = scheduledAt, ScheduledAt = scheduledAt,
ThreadId = messageThreadId ThreadId = messageThreadId,
Status = SessionStatus.Planned
}, },
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "Planned")); sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned));
} }
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
@@ -21,10 +22,10 @@ public sealed class ExportCalendarHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId WHERE g.telegram_chat_id = @ChatId
AND s.status = 'Planned' AND s.status = @Planned
AND s.scheduled_at > NOW() AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -80,10 +80,10 @@ public sealed class DeleteSessionHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId }); new { ChatId = command.ChatId, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -23,10 +23,10 @@ public sealed class ListSessionsHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -154,10 +154,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now() UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -165,13 +165,13 @@ public sealed class HandleRescheduleVoteHandler(
""" """
UPDATE sessions UPDATE sessions
SET scheduled_at = @NewTime, SET scheduled_at = @NewTime,
status = 'Planned', status = @Status,
confirmation_message_id = NULL, confirmation_message_id = NULL,
link_message_id = NULL, link_message_id = NULL,
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -39,9 +40,9 @@ public sealed class InitiateRescheduleHandler(
SELECT s.title AS Title, g.gm_telegram_id AS GmId SELECT s.title AS Title, g.gm_telegram_id AS GmId
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != 'Cancelled' WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId }); new { command.SessionId, Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
@@ -1,3 +1,5 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain; namespace GmRelay.Shared.Domain;
public static class SessionStatus public static class SessionStatus
@@ -6,4 +8,13 @@ public static class SessionStatus
public const string ConfirmationSent = "ConfirmationSent"; public const string ConfirmationSent = "ConfirmationSent";
public const string Confirmed = "Confirmed"; public const string Confirmed = "Confirmed";
public const string Cancelled = "Cancelled"; public const string Cancelled = "Cancelled";
public static IReadOnlySet<string> All { get; } =
new[] { Planned, ConfirmationSent, Confirmed, Cancelled }
.ToFrozenSet(StringComparer.Ordinal);
public static bool IsKnown(string status) => All.Contains(status);
public static bool IsCancelled(string status) =>
string.Equals(status, Cancelled, StringComparison.Ordinal);
} }
@@ -36,14 +36,10 @@ public static class SessionBatchRenderer
messageText += " <i>Пока никто не записался</i>\n"; messageText += " <i>Пока никто не записался</i>\n";
} }
if (session.Status == "Cancelled") if (SessionStatus.IsCancelled(session.Status))
{ {
messageText += "❌ <i>Сессия отменена</i>\n\n"; messageText += "❌ <i>Сессия отменена</i>\n\n";
} }
else if (session.Status == "RecruitmentClosed")
{
messageText += "🔒 <i>Набор завершен</i>\n\n";
}
else else
{ {
messageText += "\n"; messageText += "\n";
@@ -134,15 +134,12 @@
SessionStatus.Confirmed => "status-success", SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger", SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning", SessionStatus.ConfirmationSent => "status-warning",
"Recruiting" => "status-info", SessionStatus.Planned => "status-info",
"RecruitmentClosed" => "status-info",
_ => "status-neutral" _ => "status-neutral"
}; };
private string TranslateStatus(string status) => status switch private string TranslateStatus(string status) => status switch
{ {
"Recruiting" => "Набор",
"RecruitmentClosed" => "Набор закрыт",
SessionStatus.Planned => "Запланировано", SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждём подтверждения", SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено", SessionStatus.Confirmed => "Подтверждено",
@@ -0,0 +1,75 @@
using System.Reflection;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class SessionStatusTests
{
[Fact]
public void All_ShouldContainOnlyCanonicalSessionStatuses()
{
var allProperty = typeof(SessionStatus).GetProperty(
"All",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(allProperty);
var allStatusValues = Assert.IsAssignableFrom<IReadOnlySet<string>>(allProperty.GetValue(null));
var expectedStatusValues = new[]
{
SessionStatus.Planned,
SessionStatus.ConfirmationSent,
SessionStatus.Confirmed,
SessionStatus.Cancelled
}
.Order(StringComparer.Ordinal);
Assert.Equal(expectedStatusValues, allStatusValues.Order(StringComparer.Ordinal));
}
[Fact]
public void ProductionSources_ShouldNotReferenceLegacySessionStatuses()
{
var repositoryRoot = FindRepositoryRoot();
var productionFiles = Directory.EnumerateFiles(repositoryRoot, "*.*", SearchOption.AllDirectories)
.Where(path => IsProductionSource(path))
.ToList();
var legacyStatuses = new[] { "Recruit" + "ing", "Recruitment" + "Closed" };
var offenders = productionFiles
.SelectMany(path => legacyStatuses
.Where(status => File.ReadAllText(path).Contains(status, StringComparison.Ordinal))
.Select(status => $"{Path.GetRelativePath(repositoryRoot, path)} contains {status}"))
.ToList();
Assert.Empty(offenders);
}
private static bool IsProductionSource(string path)
{
var extension = Path.GetExtension(path);
var separator = Path.DirectorySeparatorChar;
return path.Contains($"{separator}src{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}bin{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}obj{separator}", StringComparison.Ordinal)
&& extension is ".cs" or ".razor" or ".sql";
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "GM-Relay.slnx")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Could not find repository root.");
}
}
@@ -6,7 +6,7 @@ namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchRendererTests public sealed class SessionBatchRendererTests
{ {
[Fact] [Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForClosedStatuses() public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
{ {
var firstSessionId = Guid.NewGuid(); var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid(); var secondSessionId = Guid.NewGuid();
@@ -14,9 +14,9 @@ public sealed class SessionBatchRendererTests
var sessions = new[] var sessions = new[]
{ {
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), "Planned"), new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), "Cancelled"), new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), "RecruitmentClosed") new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned)
}; };
var participants = new[] var participants = new[]
{ {
@@ -37,9 +37,12 @@ public sealed class SessionBatchRendererTests
Assert.True(secondIndex < thirdIndex); Assert.True(secondIndex < thirdIndex);
Assert.Contains("@alice", text); Assert.Contains("@alice", text);
Assert.Contains("Bob", text); Assert.Contains("Bob", text);
Assert.Single(result.Markup.InlineKeyboard); Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Collection( Assert.Collection(
buttons.Select(button => button.CallbackData), buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData)); callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData));