Skip to content

Latest commit

 

History

History
296 lines (225 loc) · 18.2 KB

README.md

File metadata and controls

296 lines (225 loc) · 18.2 KB

Warning: This project is wildly and unnecessarily overcomplicated for what it does. Proceed with humility.

Overview

This project was created to address a common frustration: manually copying participant lists, adding names, and reposting them in group chats. This workflow is inconvenient and error-prone, especially when multiple people attempt to join simultaneously, which can lead to concurrency issues.

Solution

BeachVolleyBot simplifies this process by allowing participants to join a game with a single button click, eliminating the need for manual list management in group chats.

Features

  • Game creation via Telegram inline queries, or by sending a message (in a group or DM) that @mentions the bot with the game details (the bot creates the game, deletes the original message, and posts the game itself — deletion needs the bot's delete-messages admin right)
  • Join / Leave with a single button click
  • Equipment tracking — volleyballs and nets per user
  • Time extraction from game titles (e.g. "Beach Volleyball 18:00"); any reply that mentions a time (e.g. 19:30, or "I can only make it by 19:30") joins the game at that slot. A reply that is nothing but the time is deleted once applied; one that carries other text stays in the chat
  • Location setting with Google Maps link, including live-location updates
  • Game sharing — one game can be reposted into multiple chats. Creating a game in a private DM yields a Share button to forward it to a chat; the same game keeps its participant list synchronized across every chat it appears in
  • Title editing — the game creator can rename a game by replying to its message; in the bot's DM, admins can rename any game the same way (kickoff day must remain in the future)
  • My games — the /games command in DM lists the games a user has created, with pagination and a per-game detail view from which the game can be shared again
  • Notification settings — the /notifications command in DM lists every notification type; tapping one shows what it does and whether it is on, with a button to enable or disable it
  • Game-is-on notification — when a game reaches 4 players (+1s included), everyone else in it who opted in gets a DM; it fires each time the count climbs back to 4
  • Game-time notification — when a game's day or time changes (a title edit, or a net, volleyball, time reply or leave that moves the earliest time), everyone else in it who opted in gets a DM
  • Short-of-players notification — when a game still has fewer than 4 players (+1s included) 12 hours before kickoff, everyone in it who opted in gets a DM; a scan worker sends it once, as the game crosses that mark
  • Welcome flow — the /start command (also triggered by /help) shows a welcome message
  • Group help — /help in a group sends the same help text as an ephemeral message, visible only to the person who asked; answered inside the webhook request, since Telegram expires an ephemeral reply 15 seconds after the command
  • Weather forecasts — hourly forecast for the game window, attached to the message and refreshed whenever the game changes; powered by Open-Meteo, resolved per known venue, cached in SQLite, and computed off the request path by a dedicated worker
  • Automatic forecast refresh — a scan worker re-fetches each upcoming game's forecast on a ladder that tightens toward kickoff
  • Multi-language support — English (default), Russian, Spanish
  • Concurrency handling via file-based locking
  • Asynchronous processing via file-based queues and workers
  • Rate limiting — respects Telegram API rate limits via RateLimitedBotApi
  • Message pinning — game messages are automatically pinned if the bot has permissions; past-date games are auto-unpinned when the next game is pinned, and the bot's own pin service notifications are cleaned up automatically
  • Past-game safeguards — once a game's kickoff has passed, weather refresh is disabled, sharing is blocked, and inline keyboards are stripped on interaction so old messages don't accept further actions
  • Admin panel — manage games, users, equipment, and view logs via Telegram callback interface
  • Game add-ons — pipeline of post-processing add-ons (merge consecutive slots, stylize title, weather)

Architecture

The architecture is designed with future scalability in mind. While the current implementation avoids external infrastructure, it can be easily migrated to a traditional stack if needed.

Request Flow

Telegram Webhook
  → public/tg-bot.php (validation, authentication)
    → IncomingMessageRouter
        ├→ immediate registry → ProcessorRegistry.resolveProcessor(update)
        │     ├→ InlineQueryProcessor          (inline query)
        │     ├→ CreateGameProcessor           (chosen inline result)
        │     ├→ ForwardGameProcessor          (chosen inline result)
        │     └→ GroupHelpCommandProcessor     (ephemeral commands in a group)
        └→ IncomingMessageQueueRouter → ProcessorRegistry.resolveQueueName(update)
              ├→ game_<id>       queue   (per-game serialization)
              ├→ game_new_<chat> queue   (create-from-message @mention: per-chat serialization)
              ├→ dm_<user>       queue   (per-user DM serialization)
              └→ pin_<chat>      queue   (per-chat pinning serialization)
                  → AppQueueWorker
                    → AppQueueProcessor → ProcessorRegistry.resolveProcessor(update)
                      → UpdateProcessors / UserProcessors / AdminProcessors
                          ├→ RateLimitedBotApi (rate-limited Telegram API calls)
                          └→ WeatherEnqueuer (when a game needs a forecast)
                             → weather queue
                               → WeatherQueueWorker
                                 → WeatherQueueProcessor
                                   → OpenMeteoWeatherClient (forecast fetch + cache)
                                     → GameMessageRefresher (re-render every posted message of the game — inline or chat)

WeatherScanWorker (every 5 minutes, independent of any update)
  → WeatherRefreshScheduler
    → GameRepository.findUpcoming (games kicking off within the 7-day forecast horizon)
      → WeatherRefreshLadder (is the cached forecast older than this game's rung?)
        → WeatherEnqueuer → weather queue (same path as above)

NotificationScanWorker (every 30 minutes, independent of any update)
  → ShortOfPlayersNotifier (games whose 12h-before-kickoff mark passed since the last scan, still under 4 players)
    → NotificationEnqueuer (same path as below)

NotificationEnqueuer (a game event enqueues one NotificationQueuePayload per recipient: type, game, user)
  → notifications/notification_<gameId> queue   (per-game ordering)
    → NotificationQueueWorker
      → NotificationQueueProcessor (drops payloads for a game that is gone or has kicked off)
        → NotificationSender (skips a user who left the game or didn't opt in to the type)
          ├→ GameNotificationMessageBuilder (in the recipient's language)
          └→ RateLimitedBotApi (one DM)

Routing is a ProcessorRegistry over handlers declaring matches(update) and createProcessor(sender, update), returning the first handler that matches. ProcessorRegistryFactory owns two lists: the immediate one runs inside the webhook request and is consulted first (inline queries, and the ephemeral group /help, whose reply Telegram rejects after 15 seconds); the queued one adds routeToQueue(update) via AbstractQueuedProcessorHandler and is consulted again at worker dispatch, which is why matches() must stay pure. Match patterns must be mutually exclusive across both lists, enforced by HandlerExclusivityTest. A pattern may read the database to decide — ChangeTitleHandler matches a reply only if it is a rename its author is allowed to make, which is what lets every other text reply fall to JoinWithTimeHandler — but it must stay free of side effects, since it is evaluated twice.

Project Structure

├── bin/                 # CLI scripts (migrate, run_worker)
├── config/              # Configuration constants and paths.env
├── migrations/          # SQL migrations (games, pinned messages, weather cache)
├── localization/        # Translation files (ru.json, es.json)
├── public/              # Webhook entry point (tg-bot.php)
├── src/
│   ├── Common/          # Logger, extractors, input strategies, date/time resolvers, update-id tracker
│   ├── Database/        # Connection, repositories, migrator
│   ├── Errors/          # Error types
│   ├── Game/            # Core game logic, models, add-ons (registry + WeatherAddOn, MergeConsecutiveSlotsAddOn, StylizeTitleAddOn)
│   ├── Localization/    # Translator (what the bot writes), CalendarVocabulary (what it reads)
│   ├── Log/             # Log file management
│   ├── Notifications/   # NotificationQueuePayload (one recipient each), NotificationEnqueuer, NotificationSender, MinimumPlayersNotifier, LineupChangeNotifier, KickoffChangeNotifier, ShortOfPlayersNotifier
│   ├── Processors/
│   │   ├── AdminProcessors/    # Admin panel callbacks (game / user / equipment / logs / settings)
│   │   ├── UserProcessors/     # /help (also /start), /games and /notifications commands with their callbacks
│   │   ├── UpdateProcessors/   # Game lifecycle: create, forward, join-with-time, change-title, set-location, pin-message, group help…
│   │   │   └── CallbackQuery/  # Per-game callbacks (join, leave, add/remove volleyball/net)
│   │   ├── Handlers/           # Per-update handlers (matches / createProcessor [/ routeToQueue])
│   │   │   ├── GameHandlers/      # Routed onto game_<id> queue
│   │   │   ├── GroupHandlers/     # Answered on the request — ephemeral group /help
│   │   │   ├── InlineHandlers/    # Answered on the request — inline queries, chosen results
│   │   │   ├── PinHandlers/       # Routed onto pin_<chat> queue
│   │   │   ├── PrivateHandlers/   # Routed onto dm_<user> queue
│   │   │   └── Traits/            # CallbackProcessorResolverTrait — shared callback dispatch
│   │   ├── AbstractProcessorHandler.php
│   │   ├── ProcessorRegistry.php
│   │   ├── ProcessorRegistryFactory.php
│   │   ├── AppQueueProcessor.php
│   │   ├── NotificationQueueProcessor.php
│   │   └── WeatherQueueProcessor.php
│   ├── Routing/         # IncomingMessageRouter + IncomingMessageQueueRouter (delegate to ProcessorRegistry)
│   ├── Telegram/        # Sender, MarkdownV2, rate-limited API, game message refresher
│   │   ├── CallbackData/       # CallbackData / AdminCallbackData / UserCallbackData (+ pageable interface)
│   │   ├── MessageBuilders/    # Game / list / detail / settings / share / help / log builders + factories, keyboards, warnings
│   │   └── Messages/           # Incoming and Outgoing Telegram message types
│   ├── Validator/       # Validator + rules (auth, date/time-in-title, kickoff-in-future, post request, secret token, …)
│   ├── Weather/         # Open-Meteo client, forecast cache, known venues, weather queue payloads
│   │   ├── Forecast/           # Cache, Client, GameWeatherLookup, Models, Formatter, WindowResolver
│   │   ├── Location/           # GameLocationResolver, KnownVenues catalog, VenueDirectory, Venue / VenueAlias
│   │   ├── Queue/              # WeatherEnqueuer, WeatherQueuePayload
│   │   └── Schedule/           # WeatherRefreshScheduler, WeatherRefreshLadder
│   └── Workers/         # AppQueueWorker, WeatherQueueWorker, WeatherScanWorker, NotificationQueueWorker, NotificationScanWorker
└── tests/               # PHPUnit tests (Unit + Integration)

Setup

1. Configure config/config.php and config/paths.env

config/config.php:

  • VERBOSE_LOGGING — enable/disable verbose logging (true/false).

  • BOT_USERNAME — the username of your Telegram bot (without the @ prefix), as set in BotFather.

  • TG_BOT_ACCESS_TOKEN — the HTTP API token provided by Telegram BotFather after creating your bot.

  • APP_TOKEN_HASH — a hash of your webhook secret token.

    Generate it with the following command:

    php -r 'echo password_hash("YOUR_SECRET_TOKEN", PASSWORD_DEFAULT), PHP_EOL;'

    Use the same YOUR_SECRET_TOKEN as the secret_token when calling setWebhook.

  • GAME_ADD_ONS — ordered list of add-on classes applied to each game after processing.

  • TG_MAX_REQUESTS_PER_SECOND — the maximum number of Telegram API requests per second (default: 19).

config/paths.env:

  • LOGS_DIR — path to the logs directory (relative to config/).

  • QUEUES_DIR — path to the queues directory (relative to config/).

  • DB_DATA_DIR — path to the database directory (relative to config/).

  • DB_FILENAME — SQLite database filename.

2. Run the installation script

Run the script as the same user that will execute PHP requests (e.g. www-data):

bash install.sh

This checks prerequisites, installs dependencies, creates runtime directories, applies migrations, runs all tests, and starts the workers.

3. Set up the webhook

Point Telegram to public/tg-bot.php on your server. The endpoint must be accessible over HTTPS.

4. Register the bot commands

Register the commands through BotFather or setMyCommands:

  • /help and /new_game — for the DM scope, and for the group scope with is_ephemeral: true. Without the flag, clients send them as ordinary visible messages in groups, which the bot deliberately ignores — so group help and the group wizard silently never appear.
  • /games and /notifications — for the DM scope only. They have no group handler, so registering them for groups only advertises commands that do nothing there.

5. Grant admin access

Admin access is stored per user in the role column of the users table, ordered ascending by privilege: 0 = player (default), 1 = admin, 2 = root. Admin actions require the admin or root role; root-only actions (e.g. logs) require root.

A user row is created the first time someone interacts with the bot. Once it exists, promote them with:

# admin
sqlite3 <db> "UPDATE users SET role = 1 WHERE telegram_user_id = <telegram_user_id>;"
# root
sqlite3 <db> "UPDATE users SET role = 2 WHERE telegram_user_id = <telegram_user_id>;"

Deployment

Anything that serves public/tg-bot.php over HTTPS and keeps the workers running will do. One option is release.sh, the scheme the production instance happens to use: releases are numbered bundles under the web root, bundle0 being the manual Setup above, and every release after it one command:

sudo /var/www/beachvolleybot-production/public/release.sh

It clones the next bundle, installs it, and switches nginx over only once install and tests pass. Its header comment has the layout and the host-specific constants to edit; to roll back, run make workers-stop in the new bundle first — nothing else stops it — then point nginx back at the previous one, reload it and run make workers-start there.

Whichever scheme you use, keep db, logs and queues out of the deployed tree. Their paths come from config/paths.env, resolved relative to <bundle>/config/, so with bundles they belong next to public/ where no release touches them:

LOGS_DIR=../../../logs
QUEUES_DIR=../../../queues
DB_DATA_DIR=../../../db

Workers

The project runs five workers concurrently: the app worker (processes Telegram updates from per-game / per-DM / per-chat queues), the weather worker (fetches forecasts for games), the weather scan worker (wakes every 5 minutes and enqueues the upcoming games whose forecast has aged past its ladder rung), the notification worker (sends notification DMs to the users who opted in to them), and the notification scan worker (wakes every 30 minutes and enqueues the time-based notifications, such as a game still short of players 12 hours before kickoff). All five are started automatically by install.sh.

To start them in the background:

make workers-start

App errors log to logs/app-worker-errors.log; weather errors log to logs/weather-worker-errors.log; scan errors log to logs/weather-scan-worker-errors.log; notification errors log to logs/notification-worker-errors.log; notification scan errors log to logs/notification-scan-worker-errors.log.

To restart them (stops running processes, then starts fresh ones):

make workers-restart

To stop them:

make workers-stop

To run a single worker in the foreground (stdout output, useful during development):

make app-worker-run
make weather-worker-run
make weather-scan-worker-run
make notification-worker-run
make notification-scan-worker-run

Localization

src/Localization/Translator.php looks up the English string as the key in localization/<lang>.json, falling back to English when a key is absent. The game card and admin panel are deliberately not localized.

Adding a language is two steps:

1. Output — copy a file and translate the values; no code. LocalizationFilesTest fails until the new file has all the keys.

cp localization/ru.json localization/de.json

2. Typed dates — add the language's weekday and month names to src/Localization/CalendarVocabulary.php, the sole declaration of what the bot can read, as Translator is of what it can write. CalendarVocabularyTest holds the two sides together: it fails while a language has a file but no vocabulary, while a language is missing a weekday or a month, and while a name means two things at once. Names are spelled out in full — titles do not abbreviate, and short forms like mar or sun would turn a beach or a sunny day into a date.

Testing

vendor/bin/phpunit --bootstrap tests/config.php