A Telegram bot that runs recurring music-making challenges ("rounds"). A round starts with a task/prompt (e.g., style constraint, composition rule), participants submit audio tracks to the bot, after a deadline, the bot starts voting (typically in a separate voting channel/chat).
Votes are consolidated -> winner is announced -> the next task is selected (winner-driven or poll-driven).
The system repeats indefinitely.
Core objects
SystemState: single row of "what's happening right now" (current round number, current task text, bot state, deadlines, message ids to edit/pin, etc.). You can see it being used everywhere via GetOrCreateCurrentState() and UpdateState(...) (e.g., contest pin message id, deadline UTC, current round number).
ContestState (FSM): high-level lifecycle state machine (Standby -> Contest -> Voting -> ...). Scheduling and command availability often depend on this.
ActiveContestEntry: a current-round submission (audio container message refs, author, description, per-entry vote aggregation).
Vote: (UserId, ContestEntryId, Value, Timestamp); created/updated during voting; later consolidated.
PostponeRequest: used to extend deadlines by quorum ("3 users asked to postpone").
RandomTask: fallback task source if selection/poll fails.
SyncEvent: an outbox/event log for remote Pesnocloud syncing (tracks/rounds/votes).
The lifecycle is managed by a finite state machine ('Stateless' nuget package) + a polling scheduler that emits "preview deadline hit" and "deadline hit" events.
Typical "happy path":
Standby
-> Contest (submissions open)
-> Voting (voting open)
-> FinalizingVotingRound (compute winner)
-> ChoosingNextTask (winner chooses OR poll is prepared)
-> InnerCircleVoting (optional moderation)
-> Contest (next round starts)
Alternative path for next task via poll:
Standby
-> TaskSuggestionCollection (collect suggestions)
-> TaskSuggestionVoting (vote on suggestions)
-> FinalizingNextRoundTaskPollVoting (pick suggestion)
-> Contest
The exact transitions are driven by triggers like PreviewDeadlineHit, DeadlineHit, "not enough votes", "winner chosen", "task selected", etc.
The scheduler (polling-based)
PollingStateScheduler continuously:
- Reads
SystemStatefrom the repository. - Looks at
state.NextDeadlineUTCandstate.State. - Computes preview time = deadline - preDeadlineDuration depending on state.
- Emits
PreviewDeadlineHitonce,DeadlineHitonce - Resets preview/final "signaled flags" if state changes, or deadline shifts (postpone).
Pre-deadline durations are configurable per state:
- Contest preview hours
- Voting preview hours
- TaskSuggestionCollection preview hours
TimeService sets the deadlines. Deadlines are stored as NextDeadlineUTC and set via TimeService, which:
- Can schedule "N days at HH:00" for contest rounds (typical),
- Or schedule "N hours from now" for short phases (voting/polls),
- Provides "time left" formatting in the configured announcement timezone.
- Schedules contest deadline (default fallback is
ContestDurationDays ?? 14at 22:00). - Announces/pins the task in main channel and announces in voting channel.
- Stores the pinned message id into
SystemState.CurrentTaskMessagelId. - Closes previously open postpone requests.
- Creates a new round in Pesnocloud via SyncService (
CreateRound(...)).
User sends audio to the bot (via direct message). The bot:
- Validates state is contest, author is eligible, etc.
- Forwards the audio to the configured voting channel (so everyone can listen).
- Creates/updates an
ActiveContestEntry(and stores message/container ids). - Queues a Pesnocloud
TrackAddedOrUpdatedSyncEventthroughSyncService.AddOrUpdateEntry(...).
Preview: the bot can warn about the contest deadline soon.
Deadline: the FSM moves into Voting (or Standby if not enough contestants). The scheduler is the thing that causes the trigger.
Voting mechanics
VotingController inherits VotingControllerBase<ActiveContestEntry, Vote>. Important detail: on a user's first vote, it may pre-create default votes for all* active entries except the one they voted on (so missing votes aren't "null").
VotingController.UpdateCurrentTaskMessage() edits the pinned voting deadline message in main channel (stored in SystemState.CurrentVotingDeadlineMessageId).
Preview: optional "voting about to end" announcements.
Deadline: transition into FinalizingVotingRound.
Finalization is handled as a distinct FSM state to ensure: consolidation happens once, winner selection is consistent, bot can recover/retry cleanly.
Votes are consolidated via repository methods like ConsolidateVotesForActiveEntriesGetAffected() (also used to isolate previous round tasks / increment round number).
Winner selection then drives task selection:
- Winner chooses (manual) or
- Bot kicks off a suggestion poll (below) or
- Falls back to random tasks if needed.
This is explicitly kickstartable from Standby.
Kickstart command
KickstartNextRoundTaskPollCommandHandler:
Allowed only in Standby.
Requires confirmation (inline buttons YES/NO).
Calls NextRoundTaskPollController.KickstartTaskPollAsync(user).
Collection -> voting
The scheduler also supports preview/deadline events for TaskSuggestionCollection with its own preview duration config.
Controllers = domain workflows (ContestController, VotingController, PostponeService, NextRoundTaskPollController, etc.).
StateController = orchestration of those workflows via FSM triggers.
Repository = persistence abstraction for SQLite (with an in-memory variant for tests).
Event aggregator = internal pub/sub for cross-cutting events (message deleted, bot blocked, kickstart contest, etc.). ContestController subscribes to message deletion and bot-block events, for example.
This keeps "what happens" (controllers) separate from "when/why it happens" (StateController + scheduler).
Storage is centralized behind IRepository and implemented in SQLite with explicit SQL for critical paths:
Vote creation and mass-default-vote insert is raw SQL with transactions.
Postpone requests and quorum tracking are persisted and aggregated in SQL.
Scheduling is "pull current state from DB every N ms" (DeadlinePollingPeriodMs), emit preview/deadline signals once per deadline, reset signals when deadline shifts (postpone) or state changes.
Postpone is intentionally constrained:
- Only available in Contest state.
- Only allowed to users with at least one finished entry.
- Only one open request per user.
When quorum is reached, it selects the largest postpone request as the one that actually extends the deadline and closes the rest as discarded. After postponing it updates both contest and voting task messages and announces the new deadline.
The pattern: "outbox table" (SyncEvent) + reliable poller Instead of calling Pesnocloud inline during user operations, the bot:
- Creates a
SyncEventrow (serialized DTO). - A background loop (
SyncService) polls unsynced events everyPesnocloudPollingPeriodMs. - It checks remote health (
/bot/isHealthy). - Processes events and marks them synced.
- Uses exponential backoff on failures.
Audio upload pipeline
TelegramPayloadExtractor downloads the audio file from Telegram into AppData/temp-payloads.
PesnocloudConformer converts audio to mp3 (libmp3lame, 192k) via configured FfmpegPath.
PesnocloudIngestService uploads the track to PUT /bot/track with metadata in query params and (important) uses a base64 "X-Entry-Description" header to avoid URL length limits.
Rounds are created/updated via PUT /bot/round and patched via PATCH /bot/round.
Votes are pushed as a snapshot to PUT /bot/votes.
MidvoteEntryController implements a niche feature:
- Maintains a set of "pins" (strings) that enable mid-vote submissions.
- Ensures only one midvote submission per author if they already have an active entry.
- Uses a semaphore to serialize message handling (avoid race conditions).
This is a deliberate "escape hatch" for special rounds/events.
Services/StateController.cs -- FSM orchestration (drives state transitions based on scheduler + triggers).
Services/IStateScheduler.cs -- scheduler interface (PreviewDeadlineHit / DeadlineHit).
Services/PollingStateScheduler.cs -- DB-driven polling scheduler; reads NextDeadlineUTC, computes preview instants, handles "deadline moved" resets.
Services/ContestController.cs -- contest start, pinning, deadline announcements, submissions, message deleted/bot blocked handling; calls SyncService for round/entry sync.
Services/VotingController.cs -- voting UI + vote persistence + deadline message updates; callback prefix v.
Services/PostponeService.cs -- quorum postpone logic; updates NextDeadlineUTC and announces changes.
Services/RandomTaskRepository.cs -- random fallback tasks.
Services/MidvoteEntryController.cs -- controlled "mid-vote submission" feature.
Services/Telegram/CommandManager.cs -- matches /commands, checks permissions, dispatches handlers.
Services/Telegram/DialogManager.cs -- manages interactive flows (waiting for user text/callback).
Services/Telegram/ -- wrapper around Telegram client calls, throttling, error handling.
Services/Sync/SyncService.cs -- poller + dispatcher for sync events (outbox pattern).
Services/Sync/PesnocloudIngestService.cs -- actual HTTP calls to Pesnocloud (/bot/isHealthy, /bot/track, /bot/round, /bot/votes).
Services/Sync/PesnocloudConformer.cs -- ffmpeg mp3 conversion pipeline.
Services/Sync/TelegramPayloadExtractor.cs -- downloads Telegram audio locally.
Services/Sync/DTO/ -- payload contracts (BotRoundDescriptor, BotTrackDescriptor, BotVotesSnapshot, SyncEventDto etc.).
Examples (not exhaustive, but the important "flow shapers"):
Commands/PostponeCommandHandler.cs -- user postpone requests (contest only); uses inline keyboard option selection.
Commands/RemindCommandHandler.cs -- supervisor reminder: forces warning + message updates.
Commands/SetDeadlineTimeToCommandHandler.cs -- supervisor override of deadline; requires confirmation; uses announcement timezone.
Commands/KickstartNextRoundTaskPollCommandHandler.cs -- supervisor kickstart task suggestion poll from Standby.
Command names are centrally defined in Commands/Schema.cs.
Configuration is loaded from service-config.json (via BotConfiguration.LoadOrCreate(...)) and supports environment overrides with Config__{JsonPropertyName}. It includes:
preview hours per phase,
postpone quorum and options,
min/max vote values,
Pesnocloud base uri + bot token,
polling periods,
ffmpeg path,
deployment chat ids (main/voting).
Preview lead times: BotConfiguration.DeadlineEventPreviewTimeHours
Polling interval: BotConfiguration.DeadlinePollingPeriodMs
Scheduler logic: PollingStateScheduler (preview instant, resetting on deadline shift)
Deadline set/edit: TimeService
Services/VotingController.cs (emojis, prefix, default vote logic)
BotConfiguration.PostponeQuorum, PostponeHoursAllowed, PostponeOptions
Enforcement and deadline shifting: Services/PostponeService.cs
Poll rate: PesnocloudPollingPeriodMs
Health check + endpoints: PesnocloudIngestService
Audio conversion: PesnocloudConformer (+ FfmpegPath)
Event queueing points: SyncService.AddOrUpdateEntry, CreateRound, UpdateVotes, PatchRoundInfo