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
POSTGRES_PASSWORD=StrongPasswordForDatabase
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 1.1.3
VERSION: 1.1.5
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.1.3</Version>
<Version>1.1.5</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+7 -3
View File
@@ -65,6 +65,9 @@ TELEGRAM_BOT_USERNAME=ваше_имя_бота_здесь
# Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=ваш_надежный_пароль
# Локальный порт веб-интерфейса GM-Relay
GMRELAY_WEB_PORT=8080
```
*(Опционально)* Настройте домен Telegram бота в @BotFather командой `/setdomain` для работы виджета авторизации на вашем сайте.
@@ -72,12 +75,13 @@ POSTGRES_PASSWORD=ваш_надежный_пароль
### 3. Запуск
Выполните команду:
```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:
db:
image: postgres:17-alpine
container_name: gmrelay_db
restart: always
environment:
POSTGRES_USER: gmrelay
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: gmrelay_db
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- gmrelay
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U gmrelay -d gmrelay_db" ]
interval: 3s
@@ -18,35 +17,40 @@ services:
retries: 10
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.3
container_name: gmrelay_bot
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.5
restart: always
network_mode: host
depends_on:
db:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
networks:
- gmrelay
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.3
container_name: gmrelay_web
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.5
restart: always
network_mode: host
depends_on:
db:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=127.0.0.1;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME}"
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes:
- web_keys:/app/dataprotection-keys
networks:
- gmrelay
volumes:
pgdata:
external: true
name: game_pgdata
name: ${POSTGRES_VOLUME_NAME:-game_pgdata}
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. Отменяем сессию
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. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
using Telegram.Bot;
@@ -93,7 +94,7 @@ public sealed class CreateSessionHandler(
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)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId)
RETURNING id;
""",
new
@@ -103,11 +104,12 @@ public sealed class CreateSessionHandler(
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId
ThreadId = messageThreadId,
Status = SessionStatus.Planned
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "Planned"));
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned));
}
await transaction.CommitAsync(cancellationToken);
@@ -1,5 +1,6 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
@@ -21,10 +22,10 @@ public sealed class ExportCalendarHandler(
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId
AND s.status = 'Planned'
AND s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id });
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
@@ -80,10 +80,10 @@ public sealed class DeleteSessionHandler(
FROM sessions s
JOIN game_groups g ON s.group_id = g.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
ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId });
new { ChatId = command.ChatId, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList();
@@ -23,10 +23,10 @@ public sealed class ListSessionsHandler(
FROM sessions s
JOIN game_groups g ON s.group_id = g.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
ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id });
new { ChatId = message.Chat.Id, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList();
@@ -154,10 +154,10 @@ public sealed class HandleRescheduleTimeInputHandler(
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
""",
new { NewTime = newTime, proposal.SessionId },
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
@@ -165,13 +165,13 @@ public sealed class HandleRescheduleVoteHandler(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = 'Planned',
status = @Status,
confirmation_message_id = NULL,
link_message_id = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId },
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
@@ -39,9 +40,9 @@ public sealed class InitiateRescheduleHandler(
SELECT s.title AS Title, g.gm_telegram_id AS GmId
FROM sessions s
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)
{
@@ -1,3 +1,5 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain;
public static class SessionStatus
@@ -6,4 +8,13 @@ public static class SessionStatus
public const string ConfirmationSent = "ConfirmationSent";
public const string Confirmed = "Confirmed";
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";
}
if (session.Status == "Cancelled")
if (SessionStatus.IsCancelled(session.Status))
{
messageText += "❌ <i>Сессия отменена</i>\n\n";
}
else if (session.Status == "RecruitmentClosed")
{
messageText += "🔒 <i>Набор завершен</i>\n\n";
}
else
{
messageText += "\n";
@@ -134,15 +134,12 @@
SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning",
"Recruiting" => "status-info",
"RecruitmentClosed" => "status-info",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private string TranslateStatus(string status) => status switch
{
"Recruiting" => "Набор",
"RecruitmentClosed" => "Набор закрыт",
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждём подтверждения",
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
{
[Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForClosedStatuses()
public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
{
var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid();
@@ -14,9 +14,9 @@ public sealed class SessionBatchRendererTests
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")
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), SessionStatus.Cancelled),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned)
};
var participants = new[]
{
@@ -37,9 +37,12 @@ public sealed class SessionBatchRendererTests
Assert.True(secondIndex < thirdIndex);
Assert.Contains("@alice", text);
Assert.Contains("Bob", text);
Assert.Single(result.Markup.InlineKeyboard);
Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Collection(
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($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData));