Skip to content

feat(fizruk): add Fizruk on-demand game server bot - #507

Merged
Szer merged 8 commits into
mainfrom
feat/fizruk
Sep 19, 2026
Merged

Szer merged 8 commits into
mainfrom
feat/fizruk

Conversation

@Szer

@Szer Szer commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

What is Fizruk

Fizruk (Russian for a PE teacher — the person who blows the whistle to start and
stop the games) is a new Telegram bot, src/Fizruk, that starts and stops
on-demand game server Kubernetes Deployments and shuts them down automatically
once nobody's playing. It has no database — all state lives in the cluster plus
a mounted config file — so it doesn't reuse _bot-deploy.yml (Flyway, a
WireGuard peer, post-deploy verification) or BotInfra.ScheduledJobs (which
leases jobs through a Postgres table Fizruk doesn't have). It reuses
BotInfra's webhook host, JSON logging, OpenTelemetry setup, and readiness
endpoint exactly like the other bots, over the same BOT_TELEGRAM_TOKEN /
BOT_AUTH_TOKEN env vars and X-Telegram-Bot-Api-Secret-Token webhook
validation.

Config schema

JSON file at FIZRUK_CONFIG_PATH (a mounted ConfigMap):

{
  "games": {
    "factorio": {
      "displayName": "Factorio", "namespace": "games", "deployment": "factorio",
      "podSelector": "app=factorio", "container": "factorio",
      "nodeLabelSelector": "workload=game", "address": "factorio.szer.dev:34197",
      "players": { "type": "rcon", "host": "...", "port": 27015, "passwordEnv": "FACTORIO_RCON_PASSWORD" },
      "activityRegex": "\\[(JOIN|LEAVE|CHAT)\\]",
      "idleGraceMinutes": 45, "idleWindowMinutes": 60, "startTimeoutMinutes": 20
    }
  },
  "chats": { "-5304790145": ["factorio"] }
}

chats is the whole ACL (chat id -> games it may control, no user ids).
players is optional; type is rcon, raknet, or none/absent. Config is
validated at startup (every game a chat references exists, regexes compile,
passwordEnv set when type=rcon) and fails fast with a clear error.

Command semantics

/start [game], /stop [game], /status [game], optional @botname suffix,
case-insensitive. Only chats present in chats are handled. No name given: a
chat controlling exactly one game uses it (the shortcut that keeps the
existing Factorio group working unchanged); otherwise /start//stop ask
which game, and /status reports on all of them. An explicit name outside the
chat's ACL gets "This chat controls: a, b". Start/stop are serialised per game
(a lock), so a double /start//stop is a safe no-op ("Already
running."/"Already starting."/"Already stopped."). /start scales to 1 and
spawns one watcher per game that polls every 20s until startTimeoutMinutes,
posting "ready at <address>", "Start cancelled" (if replicas dropped back to
0 first), or a timeout message. /stop probes players best-effort, scales to
0, and reports who was online at shutdown. Notifications go to every chat that
controls that game.

Idle rules

Every 10 minutes (IdleCheckHostedService, a plain BackgroundService),
IdleDecision.decide — a pure function — checks each game in order: not
running -> keep; pod not ready -> keep; pod age unknown or under
idleGraceMinutes -> keep (protects a freshly-started server from the idle
timer); player-probe error -> keep + log; players online -> keep; log-read
error -> keep + log; recent activity-log match -> keep; otherwise scale to 0
and notify "Stopped <name>: nobody online for the last N min (was up
Xh Ym)." Player probes are a from-scratch Source RCON client (Factorio) and a
RakNet unconnected-ping client (Minecraft Bedrock), both with pure
packet-framing/parsing functions. Kubernetes access goes through a small
IK8sGateway interface (KubernetesClient, in-cluster config) so GameCore
is testable with a fake.

Workflows

  • .github/workflows/fizruk-build.yml — PR gate via the shared
    _bot-build.yml, running tests/Fizruk.Tests (now including the
    container smoke tests below).
  • .github/workflows/fizruk-deploy.yml — a thin caller of the shared
    _bot-deploy.yml with has-database: false. _bot-deploy.yml grew a
    has-database input (default true, unchanged for vahter/coupon/alita)
    that gates every Flyway/migration step and the DB connectivity steps;
    migrations-path/db-name inputs and the DB_PROD_* secrets became
    non-required (workflow_call can't make secrets conditional, so the
    migration steps are the only consumers). Fizruk still gets the GHCR
    build+push, the WireGuard step, and the ArgoCD verify-deploy step, exactly
    like every other bot — it only skips the DB-specific work it has no DB for.
    The first run of this workflow will fail at verify-deploy until the
    fizruk ArgoCD app and the WIREGUARD_CONFIG_FIZRUK secret exist on the
    my-infra side; the image is already pushed to GHCR by the time that step
    runs. sre-enabled: false for now, matching alita-deploy.yml's
    not-yet-settled-in-prod convention for a brand new bot.

Container tests

tests/Fizruk.Tests also builds the real src/Dockerfile.bot image
(BOT_PROJECT=Fizruk) via Testcontainers, the same pattern
tests/CouponHubBot.Tests/tests/MultiPodTests use, and runs it against a
FakeTgApi container standing in for api.telegram.org. A new
InMemoryK8sGateway (selected via FIZRUK_FAKE_K8S=true, never set in
production) stands in for the Kubernetes API so the container needs no real
cluster — every game just reads as stopped. Covers: the image builds and the
container starts healthy (/health), a /status from an allowed chat
produces the expected sendMessage call, and a message from an unknown chat
produces none. These run in the same assembly as the rest of
tests/Fizruk.Tests (an AssemblyFixture), so fizruk-build.yml's existing
test command already covers them with no workflow change.

Deployment (follow-up, not in this PR)

A my-infra ConfigMap for FIZRUK_CONFIG_PATH, a Deployment/Service, and an
argocd-image-updater annotation on ghcr.io/szer/fizruk with tag regexp
^[0-9a-f]{40}$ — plus the ArgoCD app and WIREGUARD_CONFIG_FIZRUK secret
noted above.

Code review fixes (after initial CI green)

  • Rcon.fs: probeAsync treated the first packet after SERVERDATA_AUTH as
    the auth verdict, but Factorio (like Valve servers) echoes an empty
    SERVERDATA_RESPONSE_VALUE (type 0) first. Extracted runProtocol(stream, ...)
    over any Stream and added a bounded loop reading until the real
    SERVERDATA_AUTH_RESPONSE (type 2) before checking id = -1. New tests run the
    real protocol logic against an in-memory duplex Stream (no socket) with a
    canned two-packet auth sequence.
  • Core.fs: GameCore.Start only armed the start-timeout watcher on a fresh
    scale-up, so "Already starting." (and a Fizruk pod restart mid-start) left a
    game with no watcher and no eventual notification. EnsureWatcher is now
    also called from "Already starting.", and a new ArmPendingWatchers()
    re-arms every game already scaled up but not yet Ready — called once at
    IdleCheckHostedService startup.
  • Commands.fs: resolveGame now matches an explicit game-name argument
    case-insensitively (OrdinalIgnoreCase), resolving to the configured
    spelling regardless of how the user typed it.
  • K8sGateway.fs: pod StartTime/node CreationTimestamp now convert via a
    new asUtcOffset that treats an Unspecified DateTime.Kind as UTC (as k8s
    API timestamps always are) instead of DateTimeOffset's default constructor
    applying the local offset.

Webhook self-registration (owner decision, after the review fixes)

Fizruk can now self-register its Telegram webhook at startup. BotInfra. WebhookRegistration is a new opt-in helper (any bot can adopt it, only
Fizruk does today): unset/empty BOT_WEBHOOK_URL logs "webhook
self-registration disabled" and does nothing; when set, it logs
getWebhookInfo's before-state (best-effort — that response can't reveal the
secret token) and then unconditionally calls setWebhook (url= BOT_WEBHOOK_URL, secret_token=BOT_AUTH_TOKEN, allowed_updates= ["message"], drop_pending_updates=false), retrying a few times with
backoff and never crashing the process. Fizruk fires it from a small hosted
service without blocking the host from serving, over the same ITelegramApi
every other outbound call uses (so TELEGRAM_API_URL redirects it to
FakeTgApi in tests). New env var: BOT_WEBHOOK_URL (optional).

The container test fixture grew a second bot container (same cached image,
BOT_WEBHOOK_URL set) sharing the existing FakeTgApi/network, with its
setWebhook call snapshotted once at startup so the assertion doesn't race
other tests' ClearFakeCalls(). Two new tests: one asserts that call's url/
secret_token/allowed_updates, the other asserts the primary (webhook-url-unset)
container made none.

Test plan

  • dotnet build bots.slnx -c Release — 0 warnings, 0 errors
  • dotnet test tests/Fizruk.Tests — 68/68 passed (63 unit + 5 container, after the review-fix and webhook rounds)
  • dotnet test tests/CouponHubBot.Tests — 264/264 passed (Directory.Packages.props changed)
  • dotnet test tests/VahterBanBot.Unit.Tests — 128/128 passed (Directory.Packages.props changed)
  • podman build --format docker --build-arg BOT_PROJECT=Fizruk -f src/Dockerfile.bot . — succeeds
  • actionlint (pinned v1.7.12, matching lint-workflows.yml) — 0 findings across .github/workflows/**,
    including _bot-deploy.yml and every other bot's build/deploy callers
  • comment-ratio pre-commit hook — passes on every commit
  • CI (this PR)

🤖 Generated with Claude Code

Szer and others added 8 commits September 19, 2026 15:16
Telegram bot that starts/stops game server Deployments on /start and
/stop and scales them back to 0 once nobody's playing. Config comes
from a mounted JSON file (FIZRUK_CONFIG_PATH) mapping games to their
Deployment/pod selectors and chats to the games they control; player
counts come from a pure RCON or RakNet probe depending on the game.
Reuses BotInfra's webhook/observability/readiness plumbing like the
other bots but has no database — idle checks run on a plain
BackgroundService instead of BotInfra.ScheduledJobs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Covers command parsing and game-ACL resolution (including the
single-game-chat shortcut and unauthorized-chat cases), IdleDecision.
decide table-driven over every rule, RCON packet framing and player
parsing, RakNet ping/pong packet bytes, config validation failures,
and start/stop/status/watcher/idle-stop flows against a fake
Kubernetes gateway and a fake notifier. No database, no containers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fizruk-build.yml runs the PR gate via the shared _bot-build.yml.
fizruk-deploy.yml pushes ghcr.io/szer/fizruk on merge to main; it
does not call _bot-deploy.yml since that requires Flyway, a database,
and a WireGuard peer Fizruk doesn't have — it copies just the GHCR
login/buildx/build-push steps, matching the arm64 platform the other
bots deploy to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds it to the bot table and repository-structure listing, plus a
short section covering commands, config schema, idle shutdown, env
vars, and the GHCR image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_bot-deploy.yml grows a has-database input (default true) that gates
every Flyway/migration step and the DB_PROD_* secrets, which become
optional (workflow_call can't make secrets conditional, so the
migration steps are the only consumers). Existing callers are
unaffected — they keep passing migrations-path/db-name/DB secrets
exactly as before. fizruk-deploy.yml is now a thin caller with
has-database: false, replacing the bespoke workflow that duplicated
the GHCR build+push steps; it gets the WireGuard step and ArgoCD
verify-deploy for free, like every other bot.

The first run of this workflow will fail at verify-deploy until the
fizruk ArgoCD app and the WIREGUARD_CONFIG_FIZRUK secret exist on the
my-infra side — the image is already pushed to GHCR by then.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Builds src/Dockerfile.bot with BOT_PROJECT=Fizruk (same as the other
bots' container fixtures) and runs it against a FakeTgApi container.
FIZRUK_FAKE_K8S=true selects the new InMemoryK8sGateway so the
container needs no real cluster. Covers: the image builds and the
container starts healthy (/health), a /status from an allowed chat
produces the expected sendMessage, and a message from an unknown
chat produces none. Runs in the same tests/Fizruk.Tests project and
assembly (AssemblyFixture), picked up by fizruk-build.yml's existing
test command with no workflow change needed.

Uses a host-file bind mount (not Testcontainers' WithResourceMapping)
for the mounted config — WithResourceMapping's docker-cp tar-stream
copy 500s ("broken pipe") against the local podman version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…her re-arming, and case-insensitive game names

- Rcon.probeAsync treated the first packet after SERVERDATA_AUTH as the
  auth verdict, but Factorio (like Valve servers) echoes an empty
  SERVERDATA_RESPONSE_VALUE (type 0) first. Extracted the auth+exec
  exchange into runProtocol(stream, ...) over any Stream, and added a
  bounded loop that reads until the real SERVERDATA_AUTH_RESPONSE
  (type 2) is seen before checking id = -1.
- GameCore.Start only armed the start-timeout watcher on a fresh scale-
  up; the "Already starting." branch (and a Fizruk pod restart mid-
  start) left a game with no watcher and therefore no eventual "ready"
  or timeout notification. EnsureWatcher is now also called from
  "Already starting.", and a new ArmPendingWatchers() re-arms every
  game already scaled up but not yet Ready — called once at
  IdleCheckHostedService startup.
- Commands.resolveGame matched an explicit game-name argument case-
  sensitively; it's now OrdinalIgnoreCase, resolving to the
  configured spelling regardless of how the user typed it.
- K8sGateway now converts pod StartTime / node CreationTimestamp via
  asUtcOffset, which treats an Unspecified DateTime.Kind as UTC (as
  k8s API timestamps always are) instead of letting DateTimeOffset's
  default constructor apply the local offset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… is set

Adds BotInfra.WebhookRegistration, an opt-in helper any bot can adopt:
when the given webhook URL is blank it logs "webhook self-registration
disabled" and does nothing; otherwise it logs getWebhookInfo's
before-state (best-effort — the secret token isn't in that response,
so this can't gate on it) and then always calls setWebhook
(url=BOT_WEBHOOK_URL, secret_token=BOT_AUTH_TOKEN,
allowed_updates=["message"], drop_pending_updates=false), retrying a
few times with backoff and never throwing.

Fizruk wires it up via a small hosted service that fires the call
without blocking the host from serving, using its existing
ITelegramApi (so TELEGRAM_API_URL redirects it to FakeTgApi in tests
exactly like every other outbound call). Only Fizruk's Program.fs
calls it — no other bot's behavior changes.

Extends the existing container fixture with a second bot container
(same cached image, BOT_WEBHOOK_URL set) sharing the same FakeTgApi
and network, and snapshots its setWebhook call once at startup so the
assertion is independent of other tests' ClearFakeCalls().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Szer
Szer merged commit 79adeee into main Sep 19, 2026
9 checks passed
@Szer
Szer deleted the feat/fizruk branch September 19, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant