feat: support co-gm group delegation
Deploy Telegram Bot / build-and-push (push) Successful in 3m51s
Deploy Telegram Bot / deploy (push) Successful in 11s

This commit is contained in:
2026-04-27 14:27:16 +03:00
parent a8f2b10956
commit 2529df4157
20 changed files with 805 additions and 63 deletions
@@ -20,6 +20,57 @@
<h2>📅 Предстоящие игры</h2>
</div>
@if (groupManagement is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Управление группой</h3>
<p>@groupManagement.Group.Name · @FormatRole(CurrentUserRole)</p>
</div>
<span class="status-badge status-info">@FormatRole(CurrentUserRole)</span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
@foreach (var manager in groupManagement.Managers)
{
<span class="status-badge @(manager.Role == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatManager(manager)
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
</div>
@if (groupManagement.CurrentUserIsOwner)
{
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Telegram ID co-GM</label>
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
<InputText @bind-Value="coGmModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@(isAddingCoGm ? "⏳ Добавляем..." : " Добавить co-GM")
</button>
</EditForm>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -212,12 +263,16 @@
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions;
private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private long? removingCoGmId;
private bool isAddingCoGm;
private long telegramId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
protected override async Task OnInitializedAsync()
{
@@ -233,6 +288,13 @@
private async Task LoadSessions()
{
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
if (groupManagement is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
if (sessions is null)
{
@@ -243,6 +305,72 @@
RebuildBatchModels();
}
private async Task AddCoGm()
{
errorMessage = null;
successMessage = null;
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
{
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
return;
}
isAddingCoGm = true;
try
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
telegramId,
coGmModel.TelegramId.Value,
coGmModel.DisplayName,
coGmModel.TelegramUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось добавить co-GM: " + ex.Message;
}
finally
{
isAddingCoGm = false;
}
}
private async Task RemoveCoGm(long coGmTelegramId)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmTelegramId;
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить co-GM: " + ex.Message;
}
finally
{
removingCoGmId = null;
}
}
private async Task PromoteWaitlisted(Guid sessionId)
{
errorMessage = null;
@@ -404,6 +532,22 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + manager.TelegramUsername;
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
}
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{
if (orderedSessions.Count < 2)
@@ -466,4 +610,11 @@
public int SessionCount { get; init; }
public string CloneInterval { get; set; } = "week";
}
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
public string DisplayName { get; set; } = "";
public string? TelegramUsername { get; set; }
}
}
@@ -1,6 +1,7 @@
@page "/"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using GmRelay.Shared.Domain
@using GmRelay.Web.Services
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@@ -43,6 +44,9 @@
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole)
</span>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры →
</a>
@@ -97,4 +101,7 @@
groups = await SessionService.GetGroupsForGmAsync(telegramId);
}
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
}
@@ -7,6 +7,24 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
{
return null;
}
var group = await sessionStore.GetGroupAsync(groupId);
if (group is null)
{
return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
return new WebGroupManagement(group, managers, isOwner);
}
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
{
if (!await GroupBelongsToGmAsync(groupId, gmId))
@@ -110,9 +128,45 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
}
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
{
if (coGmTelegramId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
}
if (ownerTelegramId == coGmTelegramId)
{
throw new InvalidOperationException("Owner is already a group manager.");
}
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
: displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
? null
: telegramUsername.Trim().TrimStart('@');
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
}
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
{
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
{
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
}
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
}
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
{
var group = await sessionStore.GetGroupAsync(groupId);
return group?.GmTelegramId == gmId;
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
}
}
@@ -6,6 +6,9 @@ public interface ISessionStore
{
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
Task<WebSessionBatch?> GetBatchAsync(Guid batchId);
@@ -15,4 +18,6 @@ public interface ISessionStore
Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode);
Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays);
Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
}
+169 -4
View File
@@ -6,7 +6,24 @@ using Telegram.Bot;
namespace GmRelay.Web.Services;
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId);
public sealed record WebGameGroup(
Guid Id,
long TelegramChatId,
string Name,
long GmTelegramId,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
public sealed record WebGroupManager(
long TelegramId,
string DisplayName,
string? TelegramUsername,
string Role,
DateTime AddedAt);
public sealed record WebGroupManagement(
WebGameGroup Group,
IReadOnlyList<WebGroupManager> Managers,
bool CurrentUserIsOwner);
public sealed record WebSession(
Guid Id,
Guid GroupId,
@@ -56,7 +73,18 @@ public sealed class SessionService(
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId",
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
gm.role AS ManagerRole
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE p.telegram_id = @GmId
ORDER BY g.name
""",
new { GmId = gmId })).ToList();
}
@@ -64,8 +92,145 @@ public sealed class SessionService(
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
"SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId",
new { GroupId = groupId });
"""
SELECT g.id,
g.telegram_chat_id AS TelegramChatId,
g.name,
g.gm_telegram_id AS GmTelegramId,
@OwnerRole AS ManagerRole
FROM game_groups g
WHERE g.id = @GroupId
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
)
""",
new { GroupId = groupId, TelegramId = telegramId });
}
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>(
"""
SELECT EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId
AND gm.role = @OwnerRole
)
""",
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
}
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
gm.role AS Role,
gm.created_at AS AddedAt
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
gm.created_at,
p.display_name
""",
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
}
public async Task AddGroupCoGmAsync(
Guid groupId,
long ownerTelegramId,
long coGmTelegramId,
string displayName,
string? telegramUsername)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username
""",
new
{
TelegramId = coGmTelegramId,
DisplayName = displayName,
TelegramUsername = telegramUsername
},
transaction);
await conn.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
SELECT @GroupId,
co_gm.id,
@CoGmRole,
owner_player.id
FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
WHERE co_gm.telegram_id = @CoGmTelegramId
ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role
ELSE EXCLUDED.role
END,
added_by_player_id = EXCLUDED.added_by_player_id
""",
new
{
GroupId = groupId,
OwnerTelegramId = ownerTelegramId,
CoGmTelegramId = coGmTelegramId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
},
transaction);
await transaction.CommitAsync();
}
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
DELETE FROM group_managers gm
USING players p
WHERE gm.player_id = p.id
AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId
AND gm.role = @CoGmRole
""",
new
{
GroupId = groupId,
CoGmTelegramId = coGmTelegramId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue
});
}
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
+1 -1
View File
@@ -1,5 +1,5 @@
/* ============================================
GM-Relay Design System v1.5.0
GM-Relay Design System v1.6.0
Dark RPG Dashboard Theme
============================================ */