This is a Telegram bot for organizing bike rides across multiple chats. The bot allows users to create, manage, and participate in bike ride events with synchronized updates across different chat groups.
The codebase follows a clean, layered architecture:
┌─────────────────────────────────────────┐
│ Telegram Bot (Grammy) │
│ /src/core/Bot.js │
└──────────────┬──────────────────────────┘
│
┌───────┴────────┐
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Commands │ │ Wizard │
│ Handlers │ │ (UI Flow) │
└──────┬──────┘ └──────┬──────┘
│ │
└───────┬────────┘
│
┌──────────▼───────────┐
│ Services │
│ - RideService │
│ - RideMessagesService│
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Storage Layer │
│ - MongoDB (prod) │
│ - Memory (dev) │
└──────────────────────┘
Layer ownership rules for the codebase are documented in docs/layer-responsibilities.md. That document is normative for deciding whether logic belongs in commands, services, the Telegram boundary, or utility helpers.
1. Entry Point (src/index.js)
- Initializes storage (MongoDB for production, in-memory for development)
- Creates and starts the Bot instance
- Simple and clean entry point
2. Bot Core (src/core/Bot.js)
-
Responsibilities:
- Initializes Grammy bot with token
- Sets up all command handlers and middleware using centralized configuration
- Manages webhook vs polling modes
- Coordinates all components (services, handlers, wizard)
-
Key Features:
- Configuration-driven architecture: Commands and handlers defined declaratively in
botConfig - Command categorization: Commands organized into
privateOnly,publicOnly, andmixedcategories - Declarative callback setup: Callback handlers configured with pattern matching
- Webhook support for production deployment
- Thread/topic support via middleware
- Private chat mode enforcement (except
/shareridein groups)
- Configuration-driven architecture: Commands and handlers defined declaratively in
3. Storage Layer (src/storage/)
Interface-based design with two implementations:
- interface.js: Defines the contract for storage operations
- mongodb.js: Production storage using MongoDB/Mongoose
- memory.js: Development storage using in-memory Map
Data Models:
- Ride: Core entity with title, date, category, ordered
routeslist ([{ url, label? }]), participation states, messages, and optionalgroupId(Telegram chat ID of an attached group) - Participant: User info (userId, username, firstName, lastName, createdAt)
- Participation: User participation states (joined, thinking, skipped) for each ride with three participation options: "I'm in", "Thinking", "Pass"
- Message: Tracks where ride announcements are posted (chatId, messageId, messageThreadId)
Route Compatibility:
- New rides use
routesas the source of truth - Legacy
routeLinkis still read for backward compatibility whenroutesis missing routes: []explicitly means the ride has no routes
Schema Versioning:
- Database migrations supported for MongoDB
- Schema version tracking in
metacollection as{schemaVersion: X} - Migration system with versioned updates and batch processing
4. Services (src/services/)
RideService (RideService.js)
- Core business logic for rides
- CRUD operations: create, update, delete, get rides
- Participant management: join/leave rides
- Cancel/resume rides
- Parse parameters and create rides from user input
- Handles route parsing, date parsing, duration parsing
- Supports multiple route links with optional labels
GroupManagementService (GroupManagementService.js)
- Group membership sync for attached Telegram groups
addParticipant(api, groupId, userId, language): unbans the user (so previously-kicked users can re-join), then creates a single-use 24-hour invite link and DMs it to the user; silently skips for group owner; logs and swallows other errorsremoveParticipant(api, groupId, userId): bans the user so they cannot re-enter until they re-join the ride; logs and swallows errors
RideMessagesService (RideMessagesService.js)
- Message synchronization across multiple chats
- Extract ride ID from messages (reply, inline, parameter)
- Create ride messages with keyboards
- Update all instances of a ride message across chats
- Clean up unavailable messages (deleted, bot kicked)
5. Command Handlers (src/commands/)
All handlers extend BaseCommandHandler which provides:
- Ride extraction and validation
- Creator permission checking
- Parameter parsing with validation
- Message update coordination
Handlers are intended to stay thin:
- they own Telegram-facing command and callback handling
- they validate entry conditions, prepare service input, and choose user-facing replies
- they should not own reusable business rules such as state-transition side effects
Some existing handlers predate this rule and are candidates for refactoring toward a thinner command layer.
Command Handlers:
- NewRideCommandHandler: Create new rides (wizard or parameters)
- UpdateRideCommandHandler: Update existing rides
- CancelRideCommandHandler: Cancel rides
- ResumeRideCommandHandler: Resume cancelled rides
- DeleteRideCommandHandler: Delete rides with confirmation
- DuplicateRideCommandHandler: Duplicate rides with modifications
- ShareRideCommandHandler: Share rides to other chats
- ListRidesCommandHandler: Paginated list of user's rides
- ListParticipantsCommandHandler: List all participants for a specific ride (shows all without truncation, organized by participation state)
- ParticipationHandlers: Join/thinking/pass ride functionality; currently includes participation-specific orchestration that should live in a dedicated participation service as the codebase is refactored
- GroupCommandHandler:
/attach #rideId(links a group to a ride, posts and pins the ride message, adds existing participants, updates all existing ride messages),/detach(unlinks the group, updates all existing ride messages), and/joinchat #rideId(private-only: sends an invite link to the ride's group chat if the user has joined the ride); attach/detach are group-chat-only - StartCommandHandler: Welcome message
- HelpCommandHandler: Multi-page help system
- FromStravaCommandHandler: Import or update a ride from a Strava club event URL; uses
StravaEventParserto fetch event data and maps it to ride fields; repeated calls with the same URL by the same user update the existing ride; imports either the attached Strava route only, or all known-provider route links from the description when no attached route exists
6. Wizard (src/wizard/RideWizard.js)
Interactive step-by-step UI for creating/updating rides:
- State management per user+chat
- Steps: title → category → organizer → date → route → distance → duration → speed → meeting point → info → confirm
- Features:
- Back/Skip/Keep/Cancel buttons
- Current value display
- Route step accepts one route per line
- Route entries support
URLorLabel | URL - Auto-parsing route info (distance/duration) from the first route that provides metrics
- When routes change, stale auto-derived distance/duration are refreshed from the new route list when available
- Admin permission checks
- Error message cleanup
- Can be restricted to private chats only
7. Formatters (src/formatters/MessageFormatter.js)
- Formats ride messages with proper HTML escaping
- Creates inline keyboards (Join/Thinking/Pass buttons)
- Formats ride lists with pagination
- Handles date/time formatting with timezone support
- Share line for creators: Shows "Share this ride:
/shareride #ID" for ride creators in private chats - Group chat line: When a group is attached to a ride (
ride.groupIdis set), shows a notice with/joinchat #IDinstructions in all ride messages; line is absent (no extra whitespace) when no group is attached - Groups ride details logically
8. Utilities (src/utils/)
- FieldProcessor.js: Centralized field processing and validation for ride parameters
- RideParamsHelper.js: Parse multi-line command parameters
- route-parser.js: Parse routes from Strava, RideWithGPS, Komoot, Garmin
- route-links.js: Shared route-list helpers: parse
Label | URL, derive provider labels, normalizeroutes, and bridge legacyrouteLink - strava-event-parser.js: Fetch and map Strava group events to ride fields; handles URL parsing, API calls, pace groups, and route enrichment
- date-input-parser.js: Natural language date parsing (chrono-node)
- date-parser.js: Format dates with timezone support
- duration-parser.js: Parse human-readable durations (2h 30m, 90m, 1.5h)
- category-utils.js: Normalize ride categories
- html-escape.js: Escape HTML for Telegram messages
- Create a ride in one chat, post to multiple chats
- All instances stay synchronized
- Join/leave updates appear everywhere
- Changes and cancellations sync automatically
- Wizard Mode: Interactive step-by-step (beginner-friendly)
- Parameter Mode: Multi-line commands (power users)
Example parameter mode:
/newride
title: Evening Ride
when: tomorrow at 6pm
category: Road Ride
meet: Bike Shop
route: https://strava.com/routes/123
route: Komoot | https://www.komoot.com/tour/456
route: Short variant | https://ridewithgps.com/routes/789
dist: 35
duration: 2h 30m
speed: 25-28
info: Bring lights
Route parameter rules:
- Repeat
route:to pass multiple routes - Use
route: URLfor an unlabeled route - Use
route: Label | URLfor a labeled route - The URL is always parsed from the last
|-separated segment, so|may be used inside labels - In
/updaterideand/dupride, any providedroute:lines replace the full route list route: -clears all routes in/updaterideand/dupride
- Reply to ride message
- Pass ID after command:
/updateride abc123 - Pass ID with #:
/updateride #abc123 - Use
id:parameter in multi-line commands
- Supports Strava, RideWithGPS, Komoot, Garmin
- Multiple route links are supported across parameter mode, wizard mode, AI mode, and Strava import
- Auto-extracts distance and duration from the first route that provides them
- Derived route labels are rendered as
Strava,Garmin,Komoot,RideWithGPS, or localized fallbackLink/Ссылка
- "tomorrow at 6pm"
- "next saturday 10am"
- "in 2 hours"
- "21 Jul 14:30"
- Regular/Mixed Ride (default)
- Road Ride
- Gravel Ride
- Mountain/Enduro/Downhill Ride
- MTB-XC Ride
- E-Bike Ride
- Virtual/Indoor Ride
Configuration (src/config.js)
Environment Variables:
BOT_TOKEN: Telegram bot tokenNODE_ENV: development/productionUSE_WEBHOOK: Enable webhook mode (vs polling)WEBHOOK_DOMAIN: Public domain for webhooksWEBHOOK_PORT: Port for webhook server (default: 8080)MONGODB_URI: MongoDB connection stringDEFAULT_TIMEZONE: Timezone for date/time displayMAX_PARTICIPANTS_DISPLAY: Maximum number of participants to show before displaying "and X more" (default: 20)
Message Templates:
- Start message
- Help messages (multi-page)
- Ride announcement template
- Button labels
- User sends
/newride→ NewRideCommandHandler - Handler checks for parameters or starts wizard
- Wizard collects data step-by-step
- On confirm: RideService.createRide() → Storage
- RideMessagesService.createRideMessage() posts to chat
- Message info stored in ride's
messagesarray
- User clicks "I'm in!" button →
ParticipationHandlers.handleJoinRide() - Extract ride ID from callback data
- RideService.setParticipation() adds participant to storage
- RideMessagesService.updateRideMessages() updates ALL instances
- All chats see updated participant list
- User replies to ride with
/updateride→UpdateRideCommandHandler - Extract ride ID from replied message
- Validate user is creator
- Start wizard or parse parameters
- RideService.updateRide() updates storage
- RideMessagesService.updateRideMessages() syncs all chats
The project uses a layered Jest strategy with clear local/CI execution modes.
Coverage includes:
- Command handlers
- Core bot setup and webhook/polling flows
- Services (
RideService,RideMessagesService) - Storage (
memory,mongodb) - Wizard flows and field configuration
- Utilities (date/route/params/duration/html/category)
- Integration tests for main user flows
- Separate full Telegram E2E smoke coverage for real-platform verification
Test scripts:
npm test: run full suite in a single passnpm run test:basic: run full suite except MongoMemoryServer testsnpm run test:mongo: run MongoMemoryServer suite onlynpm run test:coverage: full-suite coverage with thresholdsnpm run test:coverage:basic: coverage for non-Mongo suitenpm run e2e:bootstrap-session: create or refresh the real Telegram user session for E2Enpm run e2e:run: start the local development bot, wait for readiness, and run the manual real Telegram smoke testnpm run e2e:telegram: run the manual real Telegram smoke test
Runner helper:
./run-tests.sh --mode all|basic|mongo [--coverage]- Default mode is
all
Quality gates:
- Jest global coverage thresholds are enforced (
statements/lines/functions/branches). - CI runs the full suite on pull requests and pushes to
main. - No nightly-only test dependency; regression coverage is part of standard CI signal.
- Full Telegram E2E smoke tests are intentionally excluded from the default CI signal and are run manually.
- Even though they are manual, they are part of the maintained testing strategy and should be updated when real Telegram behavior changes.
Testing approach:
- Recent refactor reduced implementation-coupled assertions and favors behavior checks:
- user-visible replies and outcomes
- state transitions and persistence effects
- multi-chat propagation side effects
- Test taxonomy and conventions are documented in
docs/testing-conventions.md. - Real Telegram E2E setup and usage are documented in
e2e/README.md.
npm run dev # Uses polling + in-memory storagedocker-compose up -d -e USE_WEBHOOK='true' # Uses webhooks + MongoDBRequirements:
- HTTPS domain for webhooks
- Reverse proxy (Nginx/Caddy) for SSL termination
- MongoDB instance
✅ Clean separation of concerns (commands, services, storage)
✅ Interface-based storage design (easy to swap implementations)
✅ Comprehensive error handling
✅ Well-tested with good coverage
✅ Consistent code style
✅ Good use of async/await
✅ Proper HTML escaping for security
✅ Multi-chat synchronization is robust
No major refactoring areas identified at this time. The codebase is well-organized and maintainable.
- Feature: (Translations) Add Russian language support and set the bot's default language through environment variables.
- Feature: (Commands) Rename /postride to /shareride.
- Documentation: Improve /start message to simplify onboarding
- Feature: (Ride participation) List participants on the same line after the label, separated by commas.
- Feature: (Ride participation) Add an "I am thinking" option to indicate who might join the chat.
- Feature: (Ride participation) Add an "Not interested" option to gather responses from those who will not join the ride.
- Feature: (Ride participation) Limit the ride message to display only the first N registered participants (configurable via MAX_PARTICIPANTS_DISPLAY), and if there are more, show a label like "and X more".
- Bug: Correct dates recognition in differfent languages
- Enhancement: Clarify average speed vs minspeed vs pace
- Feature: Creating a dedicated ride chat (attach/detach an existing group via
/attach #rideId//detach; participants auto-added via invite link, auto-removed on leave) - Feature: Creating rides with AI
- Feature: Creating ride from Strava event
- Enhancement: Show preview in wizard starting from the first step and update it along with each next step
- Bug/Enhancement: Fixed routes parsing from known providers and add Garmin
- Feature: (Notifications) Send a private notification to the ride creator when a participant joins or leaves. There is a 30-second delay to prevent spamming from frequent toggling: one final notification for all changes per user with <=30 sec between the last and previous ones; if the participant's status changes again within this period, cancel the pending notification
- Enhancement: Allow attaching multiple track links to different services (explicitely labeling Komoot, Strava, Garmin, RideWithGPS)
- Feature: (Rides management) Add management buttons below the ride button in private chat with the bot for ride creator
- Feature: (Notifications) Remind the joined users about upcoming ride 24 hours and 1 hour before the ride (send a private message; give each user an option to unsubscribe from all notifications). Remind thinking that they must finilize their decision 1 hour before the ride
- Feature: (Ride sharing) Optionally enable everyone to repost the ride via an additional field during ride creation or update
- Feature: (Notifications) Sending messages to all participants who joined the ride or thinking (either one category or both together) via bot command
- Bug: Better testing and fixes of setting nummeric ride attributes, like setting interval 22-25 and updating with a single value (29) gives 29-25
- Documentation: Update readme for installation via Docker
- Bug: Resolve multiple mongoDB instances/connections
- Feature: (Translations) Override the bot's default language based on the user's preference (the bot communicates with the user in their chosen language; it also announces the user's rides, created by them, in the preferred language).
- Feature: (Ride participation) Add a comand to list joined rides for the current user
- Feature: Add keyboard shortcut to keep the current value
- Enhancement: show the list of declined people in the main messages
- Ride web-page for sharing by link
This is a well-architected Telegram bot with:
- Clean layered architecture following service/repository patterns
- Flexible command interface (wizard + parameters)
- Robust multi-chat synchronization
- Comprehensive feature set for bike ride organization
- Good test coverage
- Production-ready deployment options
The codebase is maintainable and extensible, making it suitable for adding new features or refactoring specific components.