Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions .github/workflows/_bot-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@ on:
test-command:
required: true
type: string
has-database:
type: boolean
default: true
description: "False skips every Flyway/migration and DB-connectivity step (e.g. Fizruk, which has no database)"
migrations-path:
required: true
required: false
type: string
description: "Path to migrations dir relative to repo root"
default: ""
description: "Path to migrations dir relative to repo root (required when has-database is true)"
db-name:
required: true
required: false
type: string
description: "Database name for Flyway migrations (e.g. vahter_db)"
default: ""
description: "Database name for Flyway migrations, e.g. vahter_db (required when has-database is true)"
docker-image:
required: true
type: string
Expand Down Expand Up @@ -45,11 +51,12 @@ on:
single-occupancy; sharing it across bots flaps the WG endpoint
for concurrent deploys (issue #368).
DB_PROD_HOST:
required: true
required: false
description: "Required when has-database is true — workflow_call can't make secrets conditional, so the migration steps are the only consumers"
DB_PROD_USERNAME:
required: true
required: false
DB_PROD_PASSWORD:
required: true
required: false
ARGOCD_AUTH_TOKEN:
required: true
AZURE_OPENAI_API_KEY:
Expand Down Expand Up @@ -182,9 +189,10 @@ jobs:
WIREGUARD_CONFIG: ${{ secrets.WIREGUARD_CONFIG }}
SPLIT_TUNNEL: "false"

# Unconditional: no-op if the last run's migrate succeeded (checksum-safe),
# required to clear a failed CIC migration's success=false history row.
# No-op if the last migrate succeeded; clears a failed CIC migration's
# success=false row. Skipped entirely when has-database is false.
- name: Repair production DB migration history
if: inputs.has-database
run: >-
docker run --rm
--network host
Expand All @@ -197,6 +205,7 @@ jobs:
repair -schemas="${{ env.SCHEMAS }}"

- name: Apply migrations to production DB
if: inputs.has-database
run: >-
docker run --rm
--network host
Expand All @@ -213,6 +222,7 @@ jobs:
run: sudo wg-quick down wg0 || true

- name: Upload Flyway report
if: inputs.has-database
uses: actions/upload-artifact@v4
with:
name: Database Report
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/fizruk-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: "Fizruk: Build"

on:
pull_request:
branches: [ "main" ]
workflow_dispatch:

jobs:
build:
uses: ./.github/workflows/_bot-build.yml
with:
test-command: |
dotnet test tests/Fizruk.Tests/Fizruk.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx" --logger "console;verbosity=detailed"
bot-paths-regex: '^(src/(BotInfra|Fizruk)/|src/Dockerfile\.bot$|tests/Fizruk\.Tests/|Directory\.Build\.props$|Directory\.Packages\.props$|global\.json$)'
48 changes: 48 additions & 0 deletions .github/workflows/fizruk-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: "Fizruk: Deploy"

on:
push:
branches: [ "main" ]
paths:
- 'src/BotInfra/**'
- '!src/BotInfra/**/*.md'
- 'src/Fizruk/**'
- '!src/Fizruk/**/*.md'
- 'src/Dockerfile.bot'
- 'tests/Fizruk.Tests/**'
- 'Directory.Build.props'
- 'Directory.Packages.props'
- 'global.json'
workflow_dispatch:

permissions:
# contents: write (not read) because this workflow calls _bot-deploy.yml,
# which calls _sre-agent.yml on failure — see coupon-deploy.yml for why.
contents: write
packages: write
issues: write
pull-requests: write
id-token: write
actions: read

jobs:
deploy:
uses: ./.github/workflows/_bot-deploy.yml
with:
has-database: false
test-command: |
dotnet test tests/Fizruk.Tests/Fizruk.Tests.fsproj -c Release --logger "trx;LogFileName=test-results.trx"
docker-image: ghcr.io/szer/fizruk
bot-project: Fizruk
argocd-app-name: fizruk
container-name: fizruk
# Off until the ArgoCD app exists in my-infra and has had a chance to settle —
# see other bots' alita-deploy.yml for the same new-bot convention.
sre-enabled: false
secrets:
# Dedicated single-occupancy peer (Szer/my-infra#111), not the shared
# WIREGUARD_CONFIG secret — must exist in my-infra before verify-deploy can pass.
WIREGUARD_CONFIG: ${{ secrets.WIREGUARD_CONFIG_FIZRUK }}
ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_BASE_URL: ${{ secrets.AZURE_OPENAI_BASE_URL }}
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
<!-- Azure AI Vision (OCR via the Image Analysis "read" feature; SDK-native retry) -->
<PackageVersion Include="Azure.AI.Vision.ImageAnalysis" Version="1.0.0" />

<!-- Kubernetes (Fizruk: scale Deployments, read pods/nodes/logs) -->
<PackageVersion Include="KubernetesClient" Version="19.0.2" />

<!-- Database -->
<PackageVersion Include="Dapper" Version="2.1.72" />
<PackageVersion Include="Dapper.FSharp" Version="4.10.0" />
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Monorepo for F# Telegram bots deployed to Kubernetes via ArgoCD.
|-----|-------------|------|
| [VahterBanBot](src/VahterBanBot/) | Spam moderation bot for Telegram chats | [README](src/VahterBanBot/README.md) |
| [CouponHubBot](src/CouponHubBot/) | Coupon management bot for a private community | [README](src/CouponHubBot/README.md) |
| [Fizruk](src/Fizruk/) | Starts/stops on-demand game server Deployments from Telegram | see below |

## Tech Stack

Expand All @@ -23,6 +24,7 @@ src/
BotInfra/ — shared bot infrastructure
VahterBanBot/ — VahterBanBot application
CouponHubBot/ — CouponHubBot application
Fizruk/ — Fizruk application
vahter-bot/ — Helm chart + DB migrations
coupon-hub-bot/ — Helm chart + DB migrations
Dockerfile.bot — shared Dockerfile
Expand All @@ -31,6 +33,7 @@ tests/
VahterBanBot.Tests/ — integration tests
CouponHubBot.Tests/ — integration tests
CouponHubBot.Ocr.Tests/ — OCR unit tests
Fizruk.Tests/ — hermetic unit tests (no DB, no containers)
FakeTgApi/ — fake Telegram API
FakeAzureOcrApi/ — fake Azure OCR + OpenAI API
scripts/
Expand All @@ -49,6 +52,39 @@ Each bot has thin caller workflows (`vahter-build.yml`, `coupon-build.yml`, etc.

VahterBanBot source is also synced to the [fsharplang-ru/vahter-bot](https://github.com/fsharplang-ru/vahter-bot) mirror via automated PRs.

## Fizruk

Fizruk (Russian for a PE teacher — the one who blows the whistle to start and stop
the game) is a Telegram bot that scales an on-demand game server's Kubernetes
Deployment up on `/start`, down on `/stop`, and back down automatically once nobody's
playing. Unlike the other bots it has no database — all state lives in the cluster
and in a mounted config file.

- **Commands**: `/start [game]`, `/stop [game]`, `/status [game]` (optional
`@botname` suffix, case-insensitive). If a chat controls exactly one game the name
can be omitted; a chat controlling several games must name one for `/start`/`/stop`,
while a nameless `/status` reports on all of them.
- **Config** (`FIZRUK_CONFIG_PATH`, a mounted ConfigMap): a `games` map (per game:
`displayName`, `namespace`, `deployment`, `podSelector`, `container`,
`nodeLabelSelector`, `address`, an optional `players` probe — `rcon` or `raknet` —,
`activityRegex`, `idleGraceMinutes`, `idleWindowMinutes`, `startTimeoutMinutes`) and
a `chats` map from Telegram chat id to the list of games that chat may control.
- **Idle shutdown**: every 10 minutes, a game running past its `idleGraceMinutes`
with nobody online (player probe) and no matching activity in its recent pod logs
is scaled back to 0, with every controlling chat notified.
- **Env vars**: `FIZRUK_CONFIG_PATH`, `BOT_TELEGRAM_TOKEN`, `BOT_AUTH_TOKEN`,
`BOT_USERNAME` (optional, for the `@botname` suffix), `TELEGRAM_API_URL`
(optional test override), `BOT_WEBHOOK_URL` (optional; when set, Fizruk
self-registers its webhook at startup via `BotInfra.WebhookRegistration` —
see below), and each RCON-probed game's `passwordEnv` variable (e.g.
`FACTORIO_RCON_PASSWORD`).
- **Webhook self-registration**: opt-in, via `BotInfra.WebhookRegistration`
(any bot can adopt it). Unset/empty `BOT_WEBHOOK_URL` is a no-op. When set,
Fizruk calls Telegram's `setWebhook` at startup (fire-and-forget, retried a
few times with backoff, never crashes the pod) with that URL,
`secret_token=BOT_AUTH_TOKEN`, and `allowed_updates=["message"]`.
- **GHCR image**: `ghcr.io/szer/fizruk`.

## License

MIT. See [LICENSE](LICENSE).
Expand Down
2 changes: 2 additions & 0 deletions bots.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<Project Path="src/AlitaBot/AlitaBot.fsproj" />
<Project Path="src/BotInfra/BotInfra.fsproj" />
<Project Path="src/CouponHubBot/CouponHubBot.fsproj" />
<Project Path="src/Fizruk/Fizruk.fsproj" />
<Project Path="src/VahterBanBot/VahterBanBot.fsproj" />
</Folder>
<Folder Name="/tests/">
Expand All @@ -14,6 +15,7 @@
<Project Path="tests/CouponHubBot.Tests/CouponHubBot.Tests.fsproj" />
<Project Path="tests/FakeAzureOcrApi/FakeAzureOcrApi.fsproj" />
<Project Path="tests/FakeTgApi/FakeTgApi.fsproj" />
<Project Path="tests/Fizruk.Tests/Fizruk.Tests.fsproj" />
<Project Path="tests/MultiPodTests/MultiPodTests.fsproj" />
<Project Path="tests/SerializationCompat.Tests/SerializationCompat.Tests.fsproj" />
<Project Path="tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj" />
Expand Down
1 change: 1 addition & 0 deletions src/BotInfra/BotInfra.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<Compile Include="TelegramApi.fs" />
<Compile Include="ScheduledJobs.fs" />
<Compile Include="WebhookHost.fs" />
<Compile Include="WebhookRegistration.fs" />
</ItemGroup>

<ItemGroup>
Expand Down
83 changes: 83 additions & 0 deletions src/BotInfra/WebhookRegistration.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace BotInfra

open System
open System.Threading.Tasks
open Microsoft.Extensions.Logging

/// Opt-in Telegram webhook self-registration — a bot calls `registerAsync`,
/// typically fire-and-forget from a startup hosted service (see Fizruk).
module WebhookRegistration =

let private allowedUpdates = [| "message" |]

/// Recurses (not nested in another task CE — required for a statically
/// compilable state machine) until setWebhook succeeds or attempts run out.
let rec private attemptSetWebhook
(tg: ITelegramApi)
req
(webhookUrl: string)
(maxAttempts: int)
(retryDelay: TimeSpan)
(logger: ILogger)
(n: int)
: Task =
task {
try
match! tg.Call req with
| Ok _ -> logger.LogInformation("Webhook registered: {Url}", webhookUrl)
| Error e ->
logger.LogWarning(
"setWebhook failed (attempt {Attempt}/{Max}): {Code} {Description}",
n, maxAttempts, e.ErrorCode, e.Description)
if n < maxAttempts then
do! Task.Delay retryDelay
do! attemptSetWebhook tg req webhookUrl maxAttempts retryDelay logger (n + 1)
else
logger.LogError("setWebhook giving up after {Max} attempts", maxAttempts)
with ex ->
logger.LogWarning(ex, "setWebhook threw (attempt {Attempt}/{Max})", n, maxAttempts)
if n < maxAttempts then
do! Task.Delay retryDelay
do! attemptSetWebhook tg req webhookUrl maxAttempts retryDelay logger (n + 1)
else
logger.LogError(ex, "setWebhook giving up after {Max} attempts", maxAttempts)
}
:> Task

/// getWebhookInfo can't reveal the secret token, so setWebhook always runs
/// (idempotent) when `webhookUrl` is set; getWebhookInfo only logs the before-state.
let registerAsync
(tg: ITelegramApi)
(webhookUrl: string)
(secretToken: string)
(maxAttempts: int)
(retryDelay: TimeSpan)
(logger: ILogger)
: Task =
task {
if String.IsNullOrWhiteSpace webhookUrl then
logger.LogInformation "webhook self-registration disabled"
else
try
match! tg.Call(Funogram.Telegram.Req.GetWebhookInfo.Make()) with
| Ok info ->
logger.LogInformation(
"Webhook before self-registration: url={Url} pendingUpdateCount={Pending}",
info.Url, info.PendingUpdateCount)
| Error e ->
logger.LogWarning(
"getWebhookInfo failed (logging before-state only): {Code} {Description}",
e.ErrorCode, e.Description)
with ex ->
logger.LogWarning(ex, "getWebhookInfo threw (logging before-state only)")

let req =
Funogram.Telegram.Req.SetWebhook.Make(
webhookUrl,
allowedUpdates = allowedUpdates,
dropPendingUpdates = false,
secretToken = secretToken)

do! attemptSetWebhook tg req webhookUrl maxAttempts retryDelay logger 1
}
:> Task
57 changes: 57 additions & 0 deletions src/Fizruk/BotFrontend.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace Fizruk

open System
open System.Threading.Tasks
open Funogram.Telegram.Types
open Microsoft.Extensions.Logging
open BotInfra

/// Thin Telegram frontend: parse -> resolve -> call GameCore -> reply. Game
/// semantics live in GameCore/Commands so a second frontend can reuse them.
type FizrukBotService(config: FizrukConfig, core: GameCore, tg: ITelegramApi, botUsername: string option, logger: ILogger<FizrukBotService>) =

let sendText (chatId: int64) (text: string) =
tg.CallIgnore(Funogram.Telegram.Req.SendMessage.Make(chatId, text))

let allowedGamesText (allowed: string list) = "This chat controls: " + String.Join(", ", allowed)

member _.Handle(chatId: int64, action: BotAction, gameArg: string option) : Task<unit> =
task {
Metrics.commandTotal.Add(
1L,
Collections.Generic.KeyValuePair("action", box (string action)),
Collections.Generic.KeyValuePair("game", box (gameArg |> Option.defaultValue "")))

match Commands.resolveGame config.Chats action chatId gameArg with
| Commands.Resolution.Unauthorized -> ()
| Commands.Resolution.Unknown allowed -> do! sendText chatId (allowedGamesText allowed)
| Commands.Resolution.Ambiguous allowed -> do! sendText chatId ("Which game? " + allowedGamesText allowed)
| Commands.Resolution.AllGames allowed ->
let! texts = allowed |> List.map core.Status |> Task.WhenAll
do! sendText chatId (String.Join("\n\n", texts))
| Commands.Resolution.Resolved gameId ->
let! reply =
match action with
| BotAction.Start -> core.Start gameId
| BotAction.Stop -> core.Stop gameId
| BotAction.Status -> core.Status gameId
do! sendText chatId reply
}

/// Only messages from a chat id present in `chats` are handled; everything else
/// (unknown chats, non-commands, malformed updates) is ignored silently.
member this.OnUpdate(update: Update) : Task<unit> =
task {
match update.Message |> Option.bind (fun m -> m.Text |> Option.map (fun t -> m, t)) with
| None -> ()
| Some(msg, text) ->
let chatId = msg.Chat.Id
if config.Chats.ContainsKey chatId then
match Commands.tryParse botUsername text with
| None -> ()
| Some cmd ->
try
do! this.Handle(chatId, cmd.Action, cmd.GameArg)
with ex ->
logger.LogError(ex, "Fizruk: error handling command in chat {ChatId}", chatId)
}
Loading
Loading