Compare commits

...

4 Commits

Author SHA1 Message Date
Toutsu 1f3fb6e89e fix(bot): install libgssapi-krb5-2 in runtime image
PR Checks / test-and-build (pull_request) Successful in 13m32s
Telegram bot's long-polling hangs after the first GetUpdates request
because libgssapi-krb5.so.2 is missing from the runtime-deps:10.0-noble
final image. .NET runtime attempts dlopen() of libgssapi during the
HTTPS handshake; without the library the HttpClient connection pool
enters an unrecoverable state and TelegramBotService never receives
new updates, even though SessionSchedulerService keeps sending
outgoing messages successfully.

Symptom (Loki, container gmrelaybot-bot-1):
  Telegram bot polling started
  Polling error, retrying in 5s
  Telegram.Bot.Exceptions.RequestException: Bot API Service Failure
  Cannot load library libgssapi_krb5.so.2

After the single Polling error, no Error handling update, no further
Polling error, and getUpdates from outside returns [] forever.

Fix: install libgssapi-krb5-2 alongside wget in the final stage of
src/GmRelay.Bot/Dockerfile. This also future-proofs Npgsql GSS/SSPI
Kerberos authentication for PostgreSQL.

Closes #129.

Bump version 3.9.5 -> 3.9.6
2026-06-09 12:20:32 +03:00
Toutsu e3e6e841b8 Merge pull request #128: fix(bot): keep Capacity and PickClub wizard steps consistent (v3.9.5)
Deploy Telegram Bot / build-and-push (push) Successful in 7m9s
Deploy Telegram Bot / scan-images (push) Successful in 2m23s
Deploy Telegram Bot / deploy (push) Successful in 54s
Closes #127.
2026-06-08 22:51:04 +03:00
Toutsu a0a84965b3 chore: bump version 3.9.4 -> 3.9.5
PR Checks / test-and-build (pull_request) Successful in 10m22s
Bugfix patch release for issue #127. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 22:34:27 +03:00
Toutsu 67e8d5b558 fix(bot): keep capacity and club wizard steps consistent
Fix two wizard FSM bugs reported after v3.9.4:

1. Capacity waitlist buttons could still advance the draft without a
   numeric MaxPlayers value. The final submit validation then rejected
   the draft with 'Не заполнены поля: лимит мест'. Now waitlist:on/off
   stay on Capacity until MaxPlayers is set; users must either enter a
   numeric limit or explicitly choose '♾ Без лимита'.

2. PickClub computed NextAfterVisibility before SetClubId, so the first
   club click left the wizard on PickClub and the second click advanced.
   Now ClubId is saved first and NextAfterVisibility is evaluated after
   that mutation, so a valid club click advances on the first try.

TDD:
- WaitlistChoiceWithoutCapacity_StaysOnCapacityStep covers waitlist:on/off.
- PickClub_ValidGuid_AdvancesToPublishOnFirstClick covers the single-click club path.
- Stale Capacity waitlist callback test updated to the safer no-advance contract.

Closes #127
2026-06-08 22:34:18 +03:00
8 changed files with 73 additions and 35 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.9.4
VERSION: 3.9.6
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.9.4</Version>
<Version>3.9.6</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.4
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.6
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.4
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.6
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.4
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.6
restart: always
depends_on:
db:
+4 -2
View File
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
WORKDIR /app
# Устанавливаем wget для healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
# Копируем только AOT-результаты из билда
@@ -298,13 +298,25 @@ public sealed class GameCreationWizard
: (null, "Неверная длительность"),
};
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
"no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)),
_ => (null, "Неизвестный выбор"),
};
if (choice is "no_limit")
{
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
}
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
{
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
}
return choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
}
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{
@@ -316,9 +328,15 @@ public sealed class GameCreationWizard
};
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id)
? (NextAfterVisibility(p), SetClubId(p, id))
: (null, "Неверный идентификатор клуба");
{
if (!Guid.TryParse(choice, out var id))
{
return (null, "Неверный идентификатор клуба");
}
var error = SetClubId(p, id);
return (NextAfterVisibility(p), error);
}
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.9.4</div>
<div class="nav-version">v3.9.6</div>
</div>
</Authorized>
<NotAuthorized>
@@ -20,9 +20,7 @@ public sealed class GameCreationWizardStepTransitionsTests
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
// Capacity → Visibility (only explicit no-limit can skip numeric capacity)
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
@@ -98,13 +96,11 @@ public sealed class GameCreationWizardStepTransitionsTests
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
{
// The wizard's callback parser uses the step encoded in the callback
// (not the draft's current step) to drive transitions. So a stale
// "Capacity" button pressed while the user is on System will in fact
// move the draft forward as if they had pressed it on Capacity. We
// lock that behaviour in.
// A stale waitlist button from Capacity must not move a draft forward
// unless MaxPlayers is already set. Otherwise users can reach Confirm
// with a missing capacity and get "Не заполнены поля: лимит мест".
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System);
drafts.Seed(draft);
@@ -112,17 +108,13 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Fact]
public async Task PickClub_ValidGuid_ReachesStableStep()
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
{
// The wizard has a quirk: NextAfterVisibility is evaluated before
// SetClubId, so a single click leaves the draft still on PickClub.
// We assert that the wizard does NOT throw and the messenger is asked
// to re-render (i.e. the handler ran end-to-end).
var wizard = BuildWizard(out var drafts, out var messenger);
var wizard = BuildWizard(out var drafts, out _);
var clubId = Guid.NewGuid();
var payload = new WizardPayload
{
@@ -138,8 +130,11 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
Assert.NotEmpty(messenger.Edits);
Assert.Equal(WizardStepNames.Publish, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("clubId", out var clubIdJson));
Assert.Equal(clubId, clubIdJson.GetGuid());
}
[Fact]
@@ -136,6 +136,29 @@ public sealed class GameCreationWizardValidationTests
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("waitlist:on")]
[InlineData("waitlist:off")]
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity,
new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
});
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("13")]