diff --git a/docs/changes/participation-notifications-specification.md b/docs/changes/participation-notifications-specification.md new file mode 100644 index 0000000..587b340 --- /dev/null +++ b/docs/changes/participation-notifications-specification.md @@ -0,0 +1,179 @@ +# Specification: Participation Notifications + +## Overview + +Ride creators can opt in to receive a private Telegram DM whenever a participant joins, starts thinking about, or declines their ride. Notifications are debounced: if the same participant changes status multiple times within 20 seconds, only one message is sent reflecting the final state. The preference is stored per-ride and defaults to **yes**. + +--- + +## User Flow + +1. Creator starts `/newride` (wizard mode) or uses the parametrized command +2. After the "Additional info" step, a new **"Participation notifications"** step appears with Yes / No buttons (default: Yes) +3. Creator chooses Yes or No and proceeds to the confirmation screen +4. Confirmation screen includes a "🔔 Participation notifications" row showing the chosen value +5. After the ride is created, whenever a participant changes their status the creator receives a DM within 20 seconds + +--- + +## Wizard Step: `notify` + +Inserted between `info` and `confirm`. + +**Position in flow:** title → category → organizer → date → route → distance → duration → speed → meet → info → **notify** → confirm + +**Prompt:** `🔔 Notify you when participants join or leave?` + +**Input method:** Inline keyboard buttons only (no text input accepted for this step) + +| Button | Action | Effect | +|--------|--------|--------| +| Yes | `wizard:notifyYes` | Sets `notifyOnParticipation = true`, advances to confirm | +| No | `wizard:notifyNo` | Sets `notifyOnParticipation = false`, advances to confirm | +| ← Back | `wizard:back` | Returns to `info` step | +| Cancel | `wizard:cancel` | Cancels the wizard | + +**Properties:** +- `required: true` — the step is always shown; the user must choose Yes or No +- `skippable: false` — no skip button +- `clearable: false` — no clear button +- Default value: `true` (set when wizard starts, shown as current value) + +**Back from confirm:** The back button on the confirm screen now goes to `notify` (previously went to `info`). + +--- + +## Parametrized Mode + +A `notify` parameter is accepted in `/newride`, `/updateride`, and `/dupride` text-based input. + +**Accepted values:** + +| Input | Result | +|-------|--------| +| `yes`, `true`, `1` | `notifyOnParticipation = true` | +| `no`, `false`, `0` | `notifyOnParticipation = false` | + +**Default:** `true` (when `notify` param is omitted). + +**Example:** +``` +/newride +title: Morning Gravel +when: Saturday 8am +notify: no +``` + +**Duplicate ride:** When duplicating via wizard or params mode, the `notifyOnParticipation` preference is copied from the original ride (unless `notify` is explicitly provided). + +--- + +## Notification DMs + +Sent to `ride.createdBy` (the creator's Telegram user ID) as a private message. + +### Conditions for sending + +| Condition | Behaviour | +|-----------|-----------| +| `ride.notifyOnParticipation === false` | No notification sent | +| Participant is the ride creator | No notification sent (self-join suppressed) | +| All other cases | Notification scheduled | + +### Message templates + +| State | Template (EN) | +|-------|---------------| +| `joined` | `🚴 {name} joined your ride "{title}"\n\n🔕 To stop notifications:\n
/updateride #{rideId}\nnotify: no` |
+| `thinking` | `🤔 {name} is thinking about your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no` |
+| `skipped` | `🙅 {name} declined your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no` |
+
+**Participant name format:** `First Last (@username)` when both name and username are available; falls back to `username (@username)`, then `First` alone with `(@username)`, then `Someone` if no data.
+
+**Language:** Bot default language (creator's personal language preference is not stored; can be improved in a future iteration).
+
+---
+
+## Debounce Behaviour
+
+- Each pending notification is keyed by `${rideId}:${participantUserId}`
+- When a notification is scheduled, any existing pending timer for the same key is cancelled and replaced
+- The 20-second timer starts fresh on each state change
+- Only the **final** state within the debounce window is sent
+- On successful send the timer entry is removed from the map
+
+**Example:** Alice clicks Join, then Thinking, then Skip within 10 seconds → only one DM arrives ~20s after the last click, saying she declined.
+
+---
+
+## Data Model Changes
+
+### `Ride`
+
+New optional field:
+
+```
+notifyOnParticipation: boolean — default: true
+```
+
+Existing rides without this field are treated as `true` (`?? true` fallback; no migration needed).
+
+---
+
+## i18n Keys
+
+### `commands.notifications`
+
+Each message includes a stop-notifications footer with a tappable code block.
+
+| Key | EN | RU |
+|-----|----|----|
+| `joined` | `🚴 {name} joined your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no` | `🚴 {name} присоединился к вашей поездке "{title}"\n\n🔕 Отключить уведомления:\n/updateride #{rideId}\nnotify: no` |
+| `thinking` | `🤔 {name} is thinking about your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no` | `🤔 {name} думает о вашей поездке "{title}"\n\n🔕 Отключить уведомления:\n/updateride #{rideId}\nnotify: no` |
+| `skipped` | `🙅 {name} declined your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no` | `🙅 {name} отказался от вашей поездки "{title}"\n\n🔕 Отключить уведомления:\n/updateride #{rideId}\nnotify: no` |
+
+### `wizard.prompts`
+
+| Key | EN | RU |
+|-----|----|----|
+| `notify` | `🔔 Notify you when participants join or leave?\nYou can change this later by updating the ride.` | `🔔 Уведомлять вас, когда участники присоединяются или выходят?\nЭто можно изменить позже через обновление поездки.` |
+
+### `wizard.confirm.labels`
+
+| Key | EN | RU |
+|-----|----|----|
+| `notify` | `🔔 Participation notifications` | `🔔 Уведомления об участниках` |
+
+### `params`
+
+| Key | EN | RU |
+|-----|----|----|
+| `notify` | `Notify on participation changes (yes/no)` | `Уведомлять об изменениях участников (yes/no)` |
+
+---
+
+## Files Changed
+
+| File | Type | Change |
+|------|------|--------|
+| `docs/changes/participation-notifications-specification.md` | New | This document |
+| `src/services/NotificationService.js` | New | Debounced DM service: `scheduleParticipationNotification`, `_sendNotification`, `_formatName` |
+| `src/storage/interface.js` | Modified | Add `notifyOnParticipation` to `Ride` typedef |
+| `src/storage/mongodb.js` | Modified | Add `notifyOnParticipation` to schema and `mapRideToInterface` |
+| `src/wizard/wizardFieldConfig.js` | Modified | Add `FieldType.BOOLEAN`; add `notify` field; change `info.nextStep` to `'notify'`; update `buildRideDataFromWizard` and `getConfirmationFields` |
+| `src/wizard/RideWizard.js` | Modified | Add `sendNotifyStep`; handle `notifyYes`/`notifyNo` actions; fix back-from-confirm to go to `notify` |
+| `src/commands/ParticipationHandlers.js` | Modified | Accept `notificationService` (4th param); call `scheduleParticipationNotification` on success |
+| `src/commands/UpdateRideCommandHandler.js` | Modified | Prefill `notifyOnParticipation` from existing ride in update wizard |
+| `src/commands/DuplicateRideCommandHandler.js` | Modified | Prefill `notifyOnParticipation` from original ride in duplicate wizard |
+| `src/services/RideService.js` | Modified | `duplicateRide`: copy `notifyOnParticipation` from original when `notify` param is absent |
+| `src/utils/FieldProcessor.js` | Modified | Parse `notify` param → `notifyOnParticipation` boolean |
+| `src/utils/RideParamsHelper.js` | Modified | Register `notify` as a valid param |
+| `src/core/Bot.js` | Modified | Instantiate `NotificationService`; pass to `ParticipationHandlers` |
+| `src/i18n/locales/en.js` | Modified | Add `commands.notifications`, `wizard.prompts.notify`, `wizard.confirm.labels.notify`, `params.notify` |
+| `src/i18n/locales/ru.js` | Modified | Same keys in Russian |
+| `src/__tests__/services/notification-service.test.js` | New | Debounce, opt-out, self-suppression, API failure, `_formatName` |
+| `src/__tests__/commands/participation-handlers.test.js` | Modified | Add `mockNotificationService`; add notification scheduling tests |
+| `src/__tests__/wizard/ride-wizard.test.js` | Modified | Add notify step tests (notifyYes/No, back navigation, defaults, prefill) |
+| `src/__tests__/wizard/wizard-field-config.test.js` | Modified | Add `BOOLEAN` type, `notify` field, navigation, `buildRideDataFromWizard` tests |
+| `src/__tests__/utils/field-processor.test.js` | Modified | Add `notify` param parsing tests |
+| `src/__tests__/wizard/ride-wizard-edge-cases.test.js` | Modified | Fix back-from-confirm test: now expects `notify` step, not `info` |
diff --git a/src/__tests__/commands/participation-handlers.test.js b/src/__tests__/commands/participation-handlers.test.js
index 1c9f62d..8e22c09 100644
--- a/src/__tests__/commands/participation-handlers.test.js
+++ b/src/__tests__/commands/participation-handlers.test.js
@@ -26,6 +26,8 @@ describe.each(['en', 'ru'])('ParticipationHandlers (%s)', (language) => {
let mockCtx;
const tr = (key, params = {}) => t(language, key, params, { fallbackLanguage: 'en' });
+ let mockNotificationService;
+
beforeEach(() => {
// Create mock RideService
mockRideService = {
@@ -46,7 +48,12 @@ describe.each(['en', 'ru'])('ParticipationHandlers (%s)', (language) => {
mockMessageFormatter = {
formatRideDetails: jest.fn()
};
-
+
+ // Create mock NotificationService
+ mockNotificationService = {
+ scheduleParticipationNotification: jest.fn()
+ };
+
// Create mock Grammy context
mockCtx = {
match: ['join:123', '123'],
@@ -63,9 +70,11 @@ describe.each(['en', 'ru'])('ParticipationHandlers (%s)', (language) => {
editMessageText: jest.fn().mockResolvedValue({})
}
};
-
- // Create ParticipationHandlers instance with mocks (no groupManagementService by default)
- participationHandlers = new ParticipationHandlers(mockRideService, mockMessageFormatter, mockRideMessagesService);
+
+ // Create ParticipationHandlers instance with mocks
+ participationHandlers = new ParticipationHandlers(
+ mockRideService, mockMessageFormatter, mockRideMessagesService, mockNotificationService
+ );
});
describe('handleJoinRide', () => {
@@ -131,6 +140,36 @@ describe.each(['en', 'ru'])('ParticipationHandlers (%s)', (language) => {
expect(mockCtx.answerCallbackQuery).toHaveBeenCalledWith(tr('commands.participation.joinedSuccess'));
});
+ it('should call scheduleParticipationNotification on successful join', async () => {
+ const mockRide = {
+ id: '123',
+ cancelled: false,
+ createdBy: 999,
+ notifyOnParticipation: true
+ };
+ mockRideService.getRide.mockResolvedValue(mockRide);
+ mockRideService.setParticipation.mockResolvedValue({ success: true, ride: mockRide });
+ mockRideMessagesService.updateRideMessages.mockResolvedValue({ success: true, updatedCount: 1, removedCount: 0 });
+
+ await participationHandlers.handleJoinRide(mockCtx);
+
+ expect(mockNotificationService.scheduleParticipationNotification).toHaveBeenCalledWith(
+ mockRide,
+ { userId: 456, username: 'testuser', firstName: 'Test', lastName: 'User' },
+ 'joined',
+ mockCtx.api
+ );
+ });
+
+ it('should NOT call scheduleParticipationNotification when participation unchanged', async () => {
+ mockRideService.getRide.mockResolvedValue({ id: '123', cancelled: false });
+ mockRideService.setParticipation.mockResolvedValue({ success: false, ride: null });
+
+ await participationHandlers.handleJoinRide(mockCtx);
+
+ expect(mockNotificationService.scheduleParticipationNotification).not.toHaveBeenCalled();
+ });
+
// Multi-chat propagation: just expect the simple reply
it('should report join with simple reply even after multi-chat propagation', async () => {
// Setup
@@ -280,7 +319,7 @@ describe.each(['en', 'ru'])('ParticipationHandlers (%s)', (language) => {
editMessageText: jest.fn().mockResolvedValue({})
};
handlersWithGroup = new ParticipationHandlers(
- mockRideService, mockMessageFormatter, mockRideMessagesService, mockGroupManagementService
+ mockRideService, mockMessageFormatter, mockRideMessagesService, null, mockGroupManagementService
);
});
diff --git a/src/__tests__/services/notification-service.test.js b/src/__tests__/services/notification-service.test.js
new file mode 100644
index 0000000..1aca124
--- /dev/null
+++ b/src/__tests__/services/notification-service.test.js
@@ -0,0 +1,162 @@
+/**
+ * @jest-environment node
+ */
+
+import { jest } from '@jest/globals';
+import { NotificationService } from '../../services/NotificationService.js';
+import { t } from '../../i18n/index.js';
+import { config } from '../../config.js';
+
+const tr = (key, params = {}) =>
+ t(config.i18n.defaultLanguage, key, params, { fallbackLanguage: config.i18n.fallbackLanguage });
+
+describe('NotificationService', () => {
+ let service;
+ let mockApi;
+ const ride = {
+ id: 'ride-1',
+ title: 'Morning Ride',
+ createdBy: 100,
+ notifyOnParticipation: true
+ };
+ const participant = {
+ userId: 200,
+ username: 'alice',
+ firstName: 'Alice',
+ lastName: 'Smith'
+ };
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ service = new NotificationService();
+ mockApi = { sendMessage: jest.fn().mockResolvedValue({}) };
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('scheduleParticipationNotification', () => {
+ it('sends notification after 20s', async () => {
+ service.scheduleParticipationNotification(ride, participant, 'joined', mockApi);
+ expect(mockApi.sendMessage).not.toHaveBeenCalled();
+
+ await jest.runAllTimersAsync();
+
+ expect(mockApi.sendMessage).toHaveBeenCalledTimes(1);
+ expect(mockApi.sendMessage).toHaveBeenCalledWith(
+ ride.createdBy,
+ expect.stringContaining(ride.title),
+ { parse_mode: 'HTML' }
+ );
+ });
+
+ it('debounces rapid state changes — only final state fires', async () => {
+ service.scheduleParticipationNotification(ride, participant, 'joined', mockApi);
+ service.scheduleParticipationNotification(ride, participant, 'thinking', mockApi);
+ service.scheduleParticipationNotification(ride, participant, 'skipped', mockApi);
+
+ await jest.runAllTimersAsync();
+
+ expect(mockApi.sendMessage).toHaveBeenCalledTimes(1);
+ const sentText = mockApi.sendMessage.mock.calls[0][1];
+ // The final state was 'skipped' — the message should use the skipped template
+ const expectedText = tr('commands.notifications.skipped', {
+ name: 'Alice Smith (@alice)',
+ title: ride.title,
+ rideId: ride.id
+ });
+ expect(sentText).toBe(expectedText);
+ });
+
+ it('does not send when notifyOnParticipation is false', async () => {
+ const silentRide = { ...ride, notifyOnParticipation: false };
+ service.scheduleParticipationNotification(silentRide, participant, 'joined', mockApi);
+
+ await jest.runAllTimersAsync();
+
+ expect(mockApi.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('does not send when participant is the ride creator', async () => {
+ const creatorParticipant = { ...participant, userId: ride.createdBy };
+ service.scheduleParticipationNotification(ride, creatorParticipant, 'joined', mockApi);
+
+ await jest.runAllTimersAsync();
+
+ expect(mockApi.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('sends independently for two different participants', async () => {
+ const bob = { userId: 300, username: 'bob', firstName: 'Bob', lastName: '' };
+ service.scheduleParticipationNotification(ride, participant, 'joined', mockApi);
+ service.scheduleParticipationNotification(ride, bob, 'thinking', mockApi);
+
+ await jest.runAllTimersAsync();
+
+ expect(mockApi.sendMessage).toHaveBeenCalledTimes(2);
+ });
+
+ it('uses correct message template for each state', async () => {
+ for (const state of ['joined', 'thinking', 'skipped']) {
+ service = new NotificationService();
+ service.scheduleParticipationNotification(ride, participant, state, mockApi);
+ await jest.runAllTimersAsync();
+
+ const sentText = mockApi.sendMessage.mock.calls[mockApi.sendMessage.mock.calls.length - 1][1];
+ const expectedText = tr(`commands.notifications.${state}`, {
+ name: 'Alice Smith (@alice)',
+ title: ride.title,
+ rideId: ride.id
+ });
+ expect(sentText).toBe(expectedText);
+ }
+ });
+
+ it('handles API failure gracefully — logs error, does not throw', async () => {
+ const apiError = new Error('Telegram error');
+ mockApi.sendMessage.mockRejectedValueOnce(apiError);
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ service.scheduleParticipationNotification(ride, participant, 'joined', mockApi);
+ await jest.runAllTimersAsync();
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('NotificationService'),
+ apiError
+ );
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('defaults notifyOnParticipation to true when undefined', async () => {
+ const rideWithoutFlag = { ...ride };
+ delete rideWithoutFlag.notifyOnParticipation;
+ service.scheduleParticipationNotification(rideWithoutFlag, participant, 'joined', mockApi);
+
+ await jest.runAllTimersAsync();
+
+ expect(mockApi.sendMessage).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('_formatName', () => {
+ it('formats full name with username', () => {
+ expect(service._formatName({ firstName: 'Alice', lastName: 'Smith', username: 'alice' }))
+ .toBe('Alice Smith (@alice)');
+ });
+
+ it('formats just first name with username', () => {
+ expect(service._formatName({ firstName: 'Alice', username: 'alice' }))
+ .toBe('Alice (@alice)');
+ });
+
+ it('formats username-only as "username (@username)"', () => {
+ expect(service._formatName({ username: 'alice' }))
+ .toBe('alice (@alice)');
+ });
+
+ it('falls back to "Someone" when no name data', () => {
+ expect(service._formatName({})).toBe('Someone');
+ });
+ });
+});
diff --git a/src/__tests__/utils/field-processor.test.js b/src/__tests__/utils/field-processor.test.js
index c07c85b..d32c0c4 100644
--- a/src/__tests__/utils/field-processor.test.js
+++ b/src/__tests__/utils/field-processor.test.js
@@ -5,6 +5,38 @@
import { FieldProcessor } from '../../utils/FieldProcessor.js';
describe('FieldProcessor', () => {
+ describe('processRideFields — notify param', () => {
+ it('maps notify:yes to notifyOnParticipation:true', () => {
+ const { data } = FieldProcessor.processRideFields({ notify: 'yes' });
+ expect(data.notifyOnParticipation).toBe(true);
+ });
+
+ it('maps notify:no to notifyOnParticipation:false', () => {
+ const { data } = FieldProcessor.processRideFields({ notify: 'no' });
+ expect(data.notifyOnParticipation).toBe(false);
+ });
+
+ it('maps notify:true to notifyOnParticipation:true', () => {
+ const { data } = FieldProcessor.processRideFields({ notify: 'true' });
+ expect(data.notifyOnParticipation).toBe(true);
+ });
+
+ it('maps notify:1 to notifyOnParticipation:true', () => {
+ const { data } = FieldProcessor.processRideFields({ notify: '1' });
+ expect(data.notifyOnParticipation).toBe(true);
+ });
+
+ it('maps notify:false to notifyOnParticipation:false', () => {
+ const { data } = FieldProcessor.processRideFields({ notify: 'false' });
+ expect(data.notifyOnParticipation).toBe(false);
+ });
+
+ it('does not include notifyOnParticipation when notify param absent', () => {
+ const { data } = FieldProcessor.processRideFields({ title: 'Test' });
+ expect(data).not.toHaveProperty('notifyOnParticipation');
+ });
+ });
+
describe('processSpeedField', () => {
// Range
it('parses a full range', () => {
diff --git a/src/__tests__/wizard/ride-wizard-edge-cases.test.js b/src/__tests__/wizard/ride-wizard-edge-cases.test.js
index 602279f..155b8b0 100644
--- a/src/__tests__/wizard/ride-wizard-edge-cases.test.js
+++ b/src/__tests__/wizard/ride-wizard-edge-cases.test.js
@@ -427,7 +427,7 @@ describe.each(['en', 'ru'])('RideWizard Edge Cases (%s)', (language) => {
});
describe('additional branch coverage', () => {
- it('should navigate back from confirm to info step', async () => {
+ it('should navigate back from confirm to notify step', async () => {
const ctx = createMockContext(123, 456, 'private', language);
await wizard.startWizard(ctx);
const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
@@ -437,7 +437,7 @@ describe.each(['en', 'ru'])('RideWizard Edge Cases (%s)', (language) => {
ctx.match = ['wizard:back', 'back'];
await wizard.handleWizardAction(ctx);
- expect(state.step).toBe('info');
+ expect(state.step).toBe('notify');
expect(ctx.api.editMessageText).toHaveBeenCalled();
});
diff --git a/src/__tests__/wizard/ride-wizard.test.js b/src/__tests__/wizard/ride-wizard.test.js
index 62c7116..ead1644 100644
--- a/src/__tests__/wizard/ride-wizard.test.js
+++ b/src/__tests__/wizard/ride-wizard.test.js
@@ -356,59 +356,75 @@ describe.each(['en', 'ru'])('RideWizard (%s)', (language) => {
// Set additional info
ctx.message = { text: 'Bring lights and a jacket', message_id: 6 };
await wizard.handleWizardInput(ctx);
-
+
+ // Verify we're now at the notify step
+ let lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
+ expect(lastMessage.text).toContain(tr('wizard.prompts.notify'));
+
+ // Choose notify yes → advance to confirm
+ ctx.match = ['wizard:notifyYes', 'notifyYes'];
+ await wizard.handleWizardAction(ctx);
+
// Verify we're now at the confirmation step
- const lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
+ lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
expect(lastMessage.text).toContain(tr('wizard.confirm.header', { action: tr('wizard.confirm.rideAction') }));
expect(lastMessage.text).toContain('Bring lights and a jacket');
-
+
// Confirm and create the ride
ctx.match = ['wizard:confirm', 'confirm'];
await wizard.handleWizardAction(ctx);
-
+
// Verify ride was created with additional info
const createdRide = Array.from(storage.rides.values())[0];
expect(createdRide).toBeDefined();
expect(createdRide.additionalInfo).toBe('Bring lights and a jacket');
});
-
+
test('should handle skipping additional info step', async () => {
await wizard.startWizard(ctx);
-
+
// Fill required fields first
ctx.message = { text: 'Test Ride', message_id: 2 };
await wizard.handleWizardInput(ctx);
-
+
ctx.message = { text: 'road', message_id: 3 };
await wizard.handleWizardInput(ctx);
-
+
// Set organizer
ctx.message = { text: 'Test Organizer', message_id: 4 };
await wizard.handleWizardInput(ctx);
-
+
// Set date
ctx.message = { text: 'tomorrow at 2pm', message_id: 5 };
await wizard.handleWizardInput(ctx);
-
+
// Skip to the additional info step
for (let i = 0; i < 5; i++) {
ctx.match = ['wizard:skip', 'skip'];
await wizard.handleWizardAction(ctx);
}
-
- // Skip additional info
+
+ // Skip additional info → now at notify step
ctx.match = ['wizard:skip', 'skip'];
await wizard.handleWizardAction(ctx);
-
+
+ // Verify we're now at the notify step
+ let lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
+ expect(lastMessage.text).toContain(tr('wizard.prompts.notify'));
+
+ // Choose notify yes → advance to confirm
+ ctx.match = ['wizard:notifyYes', 'notifyYes'];
+ await wizard.handleWizardAction(ctx);
+
// Verify we're now at the confirmation step
- const lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
+ lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
expect(lastMessage.text).toContain(tr('wizard.confirm.header', { action: tr('wizard.confirm.rideAction') }));
expect(lastMessage.text).not.toContain(`${tr('wizard.confirm.labels.additionalInfo')}:`);
-
+
// Confirm and create the ride
ctx.match = ['wizard:confirm', 'confirm'];
await wizard.handleWizardAction(ctx);
-
+
// Verify ride was created without additional info
const createdRide = Array.from(storage.rides.values())[0];
expect(createdRide).toBeDefined();
@@ -647,4 +663,122 @@ describe.each(['en', 'ru'])('RideWizard (%s)', (language) => {
expect(mockRideMessagesService.updateRideMessages).toHaveBeenCalledWith(updatedRide, ctx);
});
});
-});
+
+ describe('Notify Step', () => {
+ /**
+ * Helper: advance wizard to the notify step by filling all steps before it.
+ */
+ async function advanceToNotifyStep() {
+ await wizard.startWizard(ctx);
+ const inputs = [
+ 'Test Ride', // title
+ 'road', // category
+ 'Test Organizer', // organizer
+ 'tomorrow at 6pm', // date
+ ];
+ for (const text of inputs) {
+ ctx.message = { text, message_id: ctx._test.messages.length + 2 };
+ await wizard.handleWizardInput(ctx);
+ }
+ // Skip route, distance, duration, speed, meet, info
+ for (let i = 0; i < 6; i++) {
+ ctx.match = ['wizard:skip', 'skip'];
+ await wizard.handleWizardAction(ctx);
+ }
+ }
+
+ test('notifyYes sets notifyOnParticipation:true and advances to confirm', async () => {
+ await advanceToNotifyStep();
+
+ const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
+ const state = wizard.wizardStates.get(stateKey);
+ expect(state.step).toBe('notify');
+
+ ctx.match = ['wizard:notifyYes', 'notifyYes'];
+ await wizard.handleWizardAction(ctx);
+
+ expect(state.step).toBe('confirm');
+ expect(state.data.notifyOnParticipation).toBe(true);
+
+ const lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
+ expect(lastMessage.text).toContain(tr('wizard.confirm.header', { action: tr('wizard.confirm.rideAction') }));
+ });
+
+ test('notifyNo sets notifyOnParticipation:false and advances to confirm', async () => {
+ await advanceToNotifyStep();
+
+ ctx.match = ['wizard:notifyNo', 'notifyNo'];
+ await wizard.handleWizardAction(ctx);
+
+ const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
+ const state = wizard.wizardStates.get(stateKey);
+ expect(state.step).toBe('confirm');
+ expect(state.data.notifyOnParticipation).toBe(false);
+ });
+
+ test('back from confirm navigates to notify', async () => {
+ await advanceToNotifyStep();
+
+ // notifyYes → confirm
+ ctx.match = ['wizard:notifyYes', 'notifyYes'];
+ await wizard.handleWizardAction(ctx);
+
+ const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
+ const state = wizard.wizardStates.get(stateKey);
+ expect(state.step).toBe('confirm');
+
+ // back from confirm → notify
+ ctx.match = ['wizard:back', 'back'];
+ await wizard.handleWizardAction(ctx);
+
+ expect(state.step).toBe('notify');
+ const lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
+ expect(lastMessage.text).toContain(tr('wizard.prompts.notify'));
+ });
+
+ test('back from notify navigates to info', async () => {
+ await advanceToNotifyStep();
+
+ const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
+ const state = wizard.wizardStates.get(stateKey);
+ expect(state.step).toBe('notify');
+
+ ctx.match = ['wizard:back', 'back'];
+ await wizard.handleWizardAction(ctx);
+
+ expect(state.step).toBe('info');
+ const lastMessage = ctx._test.editedMessages[ctx._test.editedMessages.length - 1];
+ expect(lastMessage.text).toContain(tr('wizard.prompts.info'));
+ });
+
+ test('fresh wizard defaults notifyOnParticipation to true in state', async () => {
+ await wizard.startWizard(ctx);
+
+ const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
+ const state = wizard.wizardStates.get(stateKey);
+ expect(state.data.notifyOnParticipation).toBe(true);
+ });
+
+ test('prefilled notifyOnParticipation:false is preserved in wizard state', async () => {
+ await wizard.startWizard(ctx, { notifyOnParticipation: false });
+
+ const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
+ const state = wizard.wizardStates.get(stateKey);
+ expect(state.data.notifyOnParticipation).toBe(false);
+ });
+
+ test('text input on notify step is silently ignored — step does not advance', async () => {
+ await advanceToNotifyStep();
+
+ const stateKey = wizard.getWizardStateKey(ctx.from.id, ctx.chat.id);
+ const state = wizard.wizardStates.get(stateKey);
+ expect(state.step).toBe('notify');
+
+ ctx.message = { text: 'yes', message_id: 999 };
+ await wizard.handleWizardInput(ctx);
+
+ // Step must remain on notify — not crash or advance
+ expect(state.step).toBe('notify');
+ });
+ });
+});
diff --git a/src/__tests__/wizard/wizard-field-config.test.js b/src/__tests__/wizard/wizard-field-config.test.js
index 5143bcd..08865ef 100644
--- a/src/__tests__/wizard/wizard-field-config.test.js
+++ b/src/__tests__/wizard/wizard-field-config.test.js
@@ -31,6 +31,7 @@ describe('wizardFieldConfig', () => {
expect(FieldType.NUMBER).toBe('number');
expect(FieldType.DURATION).toBe('duration');
expect(FieldType.SPEED).toBe('speed');
+ expect(FieldType.BOOLEAN).toBe('boolean');
});
});
@@ -46,6 +47,7 @@ describe('wizardFieldConfig', () => {
expect(WIZARD_FIELDS.speed).toBeDefined();
expect(WIZARD_FIELDS.meet).toBeDefined();
expect(WIZARD_FIELDS.info).toBeDefined();
+ expect(WIZARD_FIELDS.notify).toBeDefined();
});
it('should have required properties for each field', () => {
@@ -469,7 +471,8 @@ describe('wizardFieldConfig', () => {
expect(WIZARD_FIELDS.organizer.nextStep).toBe('date');
expect(WIZARD_FIELDS.date.nextStep).toBe('route');
expect(WIZARD_FIELDS.meet.nextStep).toBe('info');
- expect(WIZARD_FIELDS.info.nextStep).toBe('confirm');
+ expect(WIZARD_FIELDS.info.nextStep).toBe('notify');
+ expect(WIZARD_FIELDS.notify.nextStep).toBe('confirm');
});
it('should define correct previous steps', () => {
@@ -477,6 +480,40 @@ describe('wizardFieldConfig', () => {
expect(WIZARD_FIELDS.category.previousStep).toBe('title');
expect(WIZARD_FIELDS.organizer.previousStep).toBe('category');
expect(WIZARD_FIELDS.date.previousStep).toBe('organizer');
+ expect(WIZARD_FIELDS.notify.previousStep).toBe('info');
+ });
+ });
+
+ describe('notify field', () => {
+ it('should be required, not skippable, not clearable', () => {
+ expect(WIZARD_FIELDS.notify.required).toBe(true);
+ expect(WIZARD_FIELDS.notify.skippable).toBe(false);
+ expect(WIZARD_FIELDS.notify.clearable).toBe(false);
+ });
+
+ it('should have BOOLEAN type', () => {
+ expect(WIZARD_FIELDS.notify.type).toBe(FieldType.BOOLEAN);
+ });
+
+ it('should have two options (yes/no)', () => {
+ expect(WIZARD_FIELDS.notify.options).toHaveLength(2);
+ expect(WIZARD_FIELDS.notify.options[0].value).toBe(true);
+ expect(WIZARD_FIELDS.notify.options[1].value).toBe(false);
+ });
+ });
+
+ describe('buildRideDataFromWizard — notifyOnParticipation', () => {
+ it('defaults notifyOnParticipation to true when not set', () => {
+ const data = buildRideDataFromWizard({ title: 'Ride', datetime: new Date() }, {});
+ expect(data.notifyOnParticipation).toBe(true);
+ });
+
+ it('preserves notifyOnParticipation:false from wizard data', () => {
+ const data = buildRideDataFromWizard(
+ { title: 'Ride', datetime: new Date(), notifyOnParticipation: false },
+ {}
+ );
+ expect(data.notifyOnParticipation).toBe(false);
});
});
});
diff --git a/src/commands/DuplicateRideCommandHandler.js b/src/commands/DuplicateRideCommandHandler.js
index 799487d..52e6634 100644
--- a/src/commands/DuplicateRideCommandHandler.js
+++ b/src/commands/DuplicateRideCommandHandler.js
@@ -50,7 +50,8 @@ export class DuplicateRideCommandHandler extends BaseCommandHandler {
duration: ride.duration,
speedMin: ride.speedMin,
speedMax: ride.speedMax,
- additionalInfo: ride.additionalInfo
+ additionalInfo: ride.additionalInfo,
+ notifyOnParticipation: ride.notifyOnParticipation ?? true
};
await this.wizard.startWizard(ctx, prefillData);
diff --git a/src/commands/ParticipationHandlers.js b/src/commands/ParticipationHandlers.js
index 2e28cd0..dd3c078 100644
--- a/src/commands/ParticipationHandlers.js
+++ b/src/commands/ParticipationHandlers.js
@@ -8,10 +8,12 @@ export class ParticipationHandlers extends BaseCommandHandler {
* @param {import('../services/RideService.js').RideService} rideService
* @param {import('../formatters/MessageFormatter.js').MessageFormatter} messageFormatter
* @param {import('../services/RideMessagesService.js').RideMessagesService} rideMessagesService
+ * @param {import('../services/NotificationService.js').NotificationService} [notificationService]
* @param {import('../services/GroupManagementService.js').GroupManagementService} [groupManagementService]
*/
- constructor(rideService, messageFormatter, rideMessagesService, groupManagementService = null) {
+ constructor(rideService, messageFormatter, rideMessagesService, notificationService = null, groupManagementService = null) {
super(rideService, messageFormatter, rideMessagesService);
+ this.notificationService = notificationService;
this.groupManagementService = groupManagementService;
}
/**
@@ -69,6 +71,9 @@ export class ParticipationHandlers extends BaseCommandHandler {
const result = await this.rideService.setParticipation(rideId, participant, state);
if (result.success) {
+ if (this.notificationService) {
+ this.notificationService.scheduleParticipationNotification(result.ride, participant, state, ctx.api);
+ }
// Sync group membership if a group is attached
if (result.ride.groupId && this.groupManagementService) {
const groupId = result.ride.groupId;
@@ -79,7 +84,6 @@ export class ParticipationHandlers extends BaseCommandHandler {
await this.groupManagementService.removeParticipant(ctx.api, groupId, userId);
}
}
-
const result2 = await this.updateRideMessage(result.ride, ctx);
if (result2.success) {
diff --git a/src/commands/UpdateRideCommandHandler.js b/src/commands/UpdateRideCommandHandler.js
index 570e741..f70ddfb 100644
--- a/src/commands/UpdateRideCommandHandler.js
+++ b/src/commands/UpdateRideCommandHandler.js
@@ -53,7 +53,8 @@ export class UpdateRideCommandHandler extends BaseCommandHandler {
duration: ride.duration,
speedMin: ride.speedMin,
speedMax: ride.speedMax,
- additionalInfo: ride.additionalInfo
+ additionalInfo: ride.additionalInfo,
+ notifyOnParticipation: ride.notifyOnParticipation ?? true
};
await this.wizard.startWizard(ctx, prefillData);
diff --git a/src/core/Bot.js b/src/core/Bot.js
index 8f9a42b..b3ba16f 100644
--- a/src/core/Bot.js
+++ b/src/core/Bot.js
@@ -19,6 +19,7 @@ import { DuplicateRideCommandHandler } from '../commands/DuplicateRideCommandHan
import { ShareRideCommandHandler } from '../commands/ShareRideCommandHandler.js';
import { ResumeRideCommandHandler } from '../commands/ResumeRideCommandHandler.js';
import { ParticipationHandlers } from '../commands/ParticipationHandlers.js';
+import { NotificationService } from '../services/NotificationService.js';
import { GroupCommandHandler } from '../commands/GroupCommandHandler.js';
import { GroupManagementService } from '../services/GroupManagementService.js';
import { replaceBotUsername } from '../utils/botUtils.js';
@@ -36,8 +37,9 @@ export class Bot {
const rideService = new RideService(storage);
const messageFormatter = new MessageFormatter();
const rideMessagesService = new RideMessagesService(rideService, messageFormatter);
+ const notificationService = new NotificationService();
this.wizard = new RideWizard(storage, rideService, messageFormatter, rideMessagesService);
- this.botConfig = this.getBotConfig(rideService, messageFormatter, rideMessagesService);
+ this.botConfig = this.getBotConfig(rideService, messageFormatter, rideMessagesService, notificationService);
// Initialize bot
this.bot = new GrammyBot(config.bot.token);
@@ -45,7 +47,7 @@ export class Bot {
this.configureBot();
}
- getBotConfig(rideService, messageFormatter, rideMessagesService) {
+ getBotConfig(rideService, messageFormatter, rideMessagesService, notificationService) {
const startHandler = new StartCommandHandler(rideService, messageFormatter, rideMessagesService);
const helpHandler = new HelpCommandHandler(rideService, messageFormatter, rideMessagesService);
const newRideHandler = new NewRideCommandHandler(rideService, messageFormatter, this.wizard, rideMessagesService);
@@ -57,7 +59,7 @@ export class Bot {
const duplicateRideHandler = new DuplicateRideCommandHandler(rideService, messageFormatter, this.wizard, rideMessagesService);
const resumeRideHandler = new ResumeRideCommandHandler(rideService, messageFormatter, rideMessagesService);
const groupManagementService = new GroupManagementService();
- const participationHandler = new ParticipationHandlers(rideService, messageFormatter, rideMessagesService, groupManagementService);
+ const participationHandler = new ParticipationHandlers(rideService, messageFormatter, rideMessagesService, notificationService, groupManagementService);
const shareRideHandler = new ShareRideCommandHandler(rideService, messageFormatter, rideMessagesService);
const groupHandler = new GroupCommandHandler(rideService, messageFormatter, rideMessagesService, groupManagementService);
diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js
index c22ebb4..b6155f9 100644
--- a/src/i18n/locales/en.js
+++ b/src/i18n/locales/en.js
@@ -284,6 +284,11 @@ Click here to start a private chat: @botname
skipped: 'skipped'
}
},
+ notifications: {
+ joined: '🚴 {name} joined your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no',
+ thinking: '🤔 {name} is thinking about your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no',
+ skipped: '🙅 {name} declined your ride "{title}"\n\n🔕 To stop notifications:\n/updateride #{rideId}\nnotify: no'
+ },
stateChange: {
onlyCreator: 'Only the ride creator can {action} this ride.',
messageUpdateError: 'Ride has been {action}, but there was an error updating the ride message. You may need to create a new ride message.'
@@ -396,7 +401,8 @@ Click here to start a private chat: @botname
duration: '⏱ Please enter the duration (e.g., \"2h 30m\", \"90m\", \"1.5h\"):\nEnter a dash (-) to clear/skip this field',
speed: '🚴 Avg speed in km/h or skip:\n• 25-28 — range\n• 25+ or 25- — minimum\n• -28 — maximum\n• 25 or ~25 — average\nEnter a dash (-) to clear/skip this field',
meet: '📍 Please enter the meeting point (or skip):\nEnter a dash (-) to clear/skip this field',
- info: 'ℹ️ Please enter any additional information (or skip):\nEnter a dash (-) to clear/skip this field'
+ info: 'ℹ️ Please enter any additional information (or skip):\nEnter a dash (-) to clear/skip this field',
+ notify: '🔔 Notify you when participants join or leave?\nYou can change this later by updating the ride.'
},
validation: {
titleRequired: 'Title cannot be empty',
@@ -417,7 +423,8 @@ Click here to start a private chat: @botname
duration: '⏱ Duration',
speed: '🚴 Avg speed',
meetingPoint: '📍 Meeting Point',
- additionalInfo: 'ℹ️ Additional Info'
+ additionalInfo: 'ℹ️ Additional Info',
+ notify: '🔔 Participation notifications'
}
}
},
@@ -444,6 +451,7 @@ Click here to start a private chat: @botname
duration: 'Duration in minutes',
speed: 'Speed: range (25-28), min (25+), max (-28), avg (25)',
info: 'Additional information',
+ notify: 'Notify on participation changes (yes/no)',
id: 'Ride ID (for commands that need it)'
},
utils: {
diff --git a/src/i18n/locales/ru.js b/src/i18n/locales/ru.js
index 46c144d..27ef875 100644
--- a/src/i18n/locales/ru.js
+++ b/src/i18n/locales/ru.js
@@ -284,6 +284,11 @@ id: abc123 (or #abc123)
skipped: 'пропускаю'
}
},
+ notifications: {
+ joined: '🚴 {name} присоединился к вашей поездке "{title}"\n\n🔕 Отключить уведомления:\n/updateride #{rideId}\nnotify: no',
+ thinking: '🤔 {name} думает о вашей поездке "{title}"\n\n🔕 Отключить уведомления:\n/updateride #{rideId}\nnotify: no',
+ skipped: '🙅 {name} отказался от вашей поездки "{title}"\n\n🔕 Отключить уведомления:\n/updateride #{rideId}\nnotify: no'
+ },
stateChange: {
onlyCreator: 'Только создатель поездки может {action} эту поездку.',
messageUpdateError: 'Поездка была {action}, но возникла ошибка при обновлении сообщения о поездке. Возможно, нужно создать новое сообщение о поездке.'
@@ -396,7 +401,8 @@ id: abc123 (or #abc123)
duration: '⏱ Введите длительность (например, \"2h 30m\", \"90m\", \"1.5h\"):\nВведите дефис (-), чтобы очистить/пропустить это поле',
speed: '🚴 Ср. скорость в км/ч или пропустите:\n• 25-28 — диапазон\n• 25+ или 25- — минимум\n• -28 — максимум\n• 25 или ~25 — среднее\nВведите дефис (-), чтобы очистить/пропустить это поле',
meet: '📍 Введите место встречи (или пропустите):\nВведите дефис (-), чтобы очистить/пропустить это поле',
- info: 'ℹ️ Введите дополнительную информацию (или пропустите):\nВведите дефис (-), чтобы очистить/пропустить это поле'
+ info: 'ℹ️ Введите дополнительную информацию (или пропустите):\nВведите дефис (-), чтобы очистить/пропустить это поле',
+ notify: '🔔 Уведомлять вас, когда участники присоединяются или выходят?\nЭто можно изменить позже через обновление поездки.'
},
validation: {
titleRequired: 'Название не может быть пустым',
@@ -417,7 +423,8 @@ id: abc123 (or #abc123)
duration: '⏱ Длительность',
speed: '🚴 Ср. скорость',
meetingPoint: '📍 Место встречи',
- additionalInfo: 'ℹ️ Дополнительно'
+ additionalInfo: 'ℹ️ Дополнительно',
+ notify: '🔔 Уведомления об участниках'
}
}
},
@@ -444,6 +451,7 @@ id: abc123 (or #abc123)
duration: 'Длительность в минутах',
speed: 'Скорость: диапазон (25-28), мин (25+), макс (-28), ср. (25)',
info: 'Дополнительная информация',
+ notify: 'Уведомлять об изменениях участников (yes/no)',
id: 'ID поездки (для команд, где требуется)'
},
utils: {
diff --git a/src/services/NotificationService.js b/src/services/NotificationService.js
new file mode 100644
index 0000000..8806922
--- /dev/null
+++ b/src/services/NotificationService.js
@@ -0,0 +1,82 @@
+import { config } from '../config.js';
+import { t } from '../i18n/index.js';
+
+const DEBOUNCE_DELAY_MS = 20_000;
+
+/**
+ * Service for sending debounced participation notifications to ride creators.
+ * When a participant's status changes, a 20-second timer is started before
+ * sending a DM to the creator. If the status changes again within that window,
+ * the pending notification is cancelled and rescheduled with the latest state.
+ */
+export class NotificationService {
+ constructor() {
+ /** @type {Map