feat(web): finalize Discord OAuth and platform-agnostic auth
PR Checks / test-and-build (pull_request) Successful in 5m47s

- Bump version to 2.8.0 across all versioned files
- Fix AuthorizedSessionServiceTests for platform-agnostic identity
- Update Razor Pages to use *ForCurrentUserAsync APIs
- Add backward-compatible constructors to WebGameGroup/WebGroupManager
- Make DiscordOAuthOptions properties non-required for config binding

Bump version → 2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:47:54 +03:00
parent 5fa7e26f72
commit 50f5307aac
15 changed files with 235 additions and 129 deletions
@@ -1,10 +1,29 @@
using GmRelay.Web.Services;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedSessionServiceTests
{
private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, externalUserId),
new Claim("TelegramId", externalUserId),
new Claim("Platform", "Telegram")
};
if (name is not null)
claims.Add(new Claim(ClaimTypes.Name, name));
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
return new HttpContextAccessor { HttpContext = httpContext };
}
[Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
{
@@ -19,9 +38,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.NotNull(sessions);
Assert.Single(sessions);
@@ -47,9 +67,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.NotNull(sessions);
Assert.Single(sessions);
@@ -65,9 +86,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.Null(sessions);
}
@@ -87,9 +109,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var session = await service.GetSessionForGmAsync(sessionId, gmId);
var session = await service.GetSessionForCurrentUserAsync(sessionId);
Assert.NotNull(session);
Assert.Equal(sessionId, session.Id);
@@ -109,9 +132,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var session = await service.GetSessionForGmAsync(sessionId, 1001L);
var session = await service.GetSessionForCurrentUserAsync(sessionId);
Assert.Null(session);
}
@@ -130,9 +154,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
var action = () => service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateCalled);
@@ -154,9 +179,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", scheduledAt, "https://example.test/b", 5);
Assert.True(store.UpdateCalled);
Assert.Equal(groupId, store.LastUpdatedGroupId);
@@ -183,9 +209,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", originalTime, "https://example.test/a", 4);
Assert.Single(store.LogEntries);
Assert.Equal("Title", store.LogEntries[0].ChangeType);
@@ -209,10 +236,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var newTime = originalTime.AddDays(1);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", newTime, "https://example.test/b", 5);
Assert.Equal(4, store.LogEntries.Count);
Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
@@ -237,9 +265,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4);
await service.UpdateSessionForCurrentUserAsync(sessionId, "Session A", originalTime, "https://example.test/a", 4);
Assert.Empty(store.LogEntries);
}
@@ -259,9 +288,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId);
var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
Assert.NotNull(history);
Assert.Empty(history);
@@ -281,9 +311,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L);
var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
Assert.Null(history);
}
@@ -303,9 +334,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId);
await service.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
Assert.True(store.PromoteCalled);
Assert.Equal(groupId, store.LastPromotedGroupId);
@@ -326,9 +358,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b");
var action = () => service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchDetailsCalled);
@@ -349,9 +382,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b");
await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
@@ -380,9 +414,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");
await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId);
@@ -400,9 +435,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", ownerId)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(ownerId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");
await service.AddCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString(), "Assistant GM", "assistant");
Assert.True(store.AddCoGmCalled);
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
@@ -427,9 +463,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);
var action = () => service.AddCoGmForOwnerAsync(groupId, "Telegram", newCoGmId.ToString(), "Second Assistant", null);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.AddCoGmCalled);
@@ -450,9 +487,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, coGmId, GroupManagerRole.CoGm)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(ownerId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);
await service.RemoveCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString());
Assert.True(store.RemoveCoGmCalled);
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
@@ -473,9 +511,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);
var action = () => service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchNotificationModeCalled);
@@ -496,9 +535,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);
await service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
Assert.True(store.UpdateBatchNotificationModeCalled);
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
@@ -521,9 +561,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
var action = () => service.RescheduleBatchForCurrentUserAsync(batchId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
Assert.False(store.RescheduleBatchCalled);
@@ -545,9 +586,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14);
await service.RescheduleBatchForCurrentUserAsync(batchId, firstScheduledAt, intervalDays: 14);
Assert.True(store.RescheduleBatchCalled);
Assert.Equal(batchId, store.LastRescheduledBatchId);
@@ -571,9 +613,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek);
await service.CloneBatchForCurrentUserAsync(batchId, BatchCloneInterval.NextWeek);
Assert.True(store.CloneBatchCalled);
Assert.Equal(batchId, store.LastClonedBatchId);
@@ -591,11 +634,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateCampaignTemplateForGmAsync(
await service.CreateCampaignTemplateForCurrentUserAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
" Weekly arc ",
" Kingmaker ",
@@ -626,11 +669,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", gmId)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateCampaignTemplateForGmAsync(
await service.CreateCampaignTemplateForCurrentUserAsync(
groupId,
gmId,
new CreateCampaignTemplateRequest(
"Open table",
"West Marches",
@@ -653,11 +696,11 @@ public sealed class AuthorizedSessionServiceTests
[
new(groupId, 42, "Alpha", 2002L)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.CreateCampaignTemplateForGmAsync(
var action = () => service.CreateCampaignTemplateForCurrentUserAsync(
groupId,
1001L,
new CreateCampaignTemplateRequest(
"Weekly arc",
"Kingmaker",
@@ -687,9 +730,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);
await service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, firstScheduledAt);
Assert.True(store.CreateBatchFromTemplateCalled);
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
@@ -711,9 +755,10 @@ public sealed class AuthorizedSessionServiceTests
[
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]);
var service = new AuthorizedSessionService(store);
var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));
var action = () => service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, DateTime.UtcNow.AddDays(3));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateBatchFromTemplateCalled);
@@ -1019,7 +1064,7 @@ public sealed class AuthorizedSessionServiceTests
public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId.ToString(), actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry);
LastLogSessionId = sessionId;
LastLogActorTelegramId = actorTelegramId;
@@ -1033,12 +1078,62 @@ public sealed class AuthorizedSessionServiceTests
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) =>
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, externalUserId)).ToList());
public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) =>
Task.FromResult(IsManager(groupId, externalUserId));
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) =>
Task.FromResult(IsOwner(groupId, externalUserId));
public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername)
{
AddCoGmCalled = true;
LastAddedCoGmGroupId = groupId;
LastAddedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
LastAddedCoGmDisplayName = displayName;
LastAddedCoGmUsername = externalUsername;
return Task.CompletedTask;
}
public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{
RemoveCoGmCalled = true;
LastRemovedCoGmGroupId = groupId;
LastRemovedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
return Task.CompletedTask;
}
public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
{
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorExternalUserId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry);
LastLogSessionId = sessionId;
LastLogActorTelegramId = long.TryParse(actorExternalUserId, out var id) ? (long?)id : null;
LastLogActorName = actorName;
LastLogChangeType = changeType;
LastLogOldValue = oldValue;
LastLogNewValue = newValue;
return Task.CompletedTask;
}
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
private bool IsOwner(Guid groupId, long telegramId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
private bool IsManager(Guid groupId, string externalUserId) =>
IsOwner(groupId, externalUserId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId.ToString() == externalUserId);
private bool IsOwner(Guid groupId, string externalUserId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId.ToString() == externalUserId;
}
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);