From 5087eae7c9394ecc1d998dd2595e39ce7ecbd69a Mon Sep 17 00:00:00 2001 From: Valery Leontyev Date: Tue, 10 Mar 2026 11:59:06 +0100 Subject: [PATCH 1/5] Feature: participation notifications with 30s debounce - Add NotificationService with debounced DMs to ride creator on join/leave - Add wizard notify step (yes/no) between info and confirm - Add notify param support in text-based ride creation/update - Store notifyOnParticipation per ride (default: true) - Wire up in Bot, ParticipationHandlers, Update/Duplicate wizards - Add comprehensive tests for NotificationService and related changes Co-Authored-By: Claude Sonnet 4.6 --- .../commands/participation-handlers.test.js | 45 ++++- .../services/notification-service.test.js | 160 ++++++++++++++++++ src/__tests__/utils/field-processor.test.js | 32 ++++ .../wizard/ride-wizard-edge-cases.test.js | 4 +- src/__tests__/wizard/ride-wizard.test.js | 154 +++++++++++++++-- .../wizard/wizard-field-config.test.js | 39 ++++- src/commands/DuplicateRideCommandHandler.js | 3 +- src/commands/ParticipationHandlers.js | 13 ++ src/commands/UpdateRideCommandHandler.js | 3 +- src/core/Bot.js | 8 +- src/i18n/locales/en.js | 12 +- src/i18n/locales/ru.js | 12 +- src/services/NotificationService.js | 75 ++++++++ src/services/RideService.js | 5 + src/storage/interface.js | 1 + src/storage/mongodb.js | 2 + src/utils/FieldProcessor.js | 8 +- src/utils/RideParamsHelper.js | 1 + src/wizard/RideWizard.js | 63 ++++++- src/wizard/wizardFieldConfig.js | 32 +++- 20 files changed, 627 insertions(+), 45 deletions(-) create mode 100644 src/__tests__/services/notification-service.test.js create mode 100644 src/services/NotificationService.js diff --git a/src/__tests__/commands/participation-handlers.test.js b/src/__tests__/commands/participation-handlers.test.js index 56c71ee..0d10e4d 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 - participationHandlers = new ParticipationHandlers(mockRideService, mockMessageFormatter, mockRideMessagesService); + 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 diff --git a/src/__tests__/services/notification-service.test.js b/src/__tests__/services/notification-service.test.js new file mode 100644 index 0000000..3ec3d34 --- /dev/null +++ b/src/__tests__/services/notification-service.test.js @@ -0,0 +1,160 @@ +/** + * @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 30s', 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 + }); + 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 + }); + 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..9889283 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,108 @@ 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); + }); + }); +}); 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 a6cb2de..10c1f1c 100644 --- a/src/commands/ParticipationHandlers.js +++ b/src/commands/ParticipationHandlers.js @@ -4,6 +4,16 @@ import { BaseCommandHandler } from './BaseCommandHandler.js'; * Handler for join/thinking/skip ride callbacks */ 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] + */ + constructor(rideService, messageFormatter, rideMessagesService, notificationService = null) { + super(rideService, messageFormatter, rideMessagesService); + this.notificationService = notificationService; + } /** * Handle join ride callback * @param {import('grammy').Context} ctx - Grammy context @@ -59,6 +69,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); + } 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 7dab0d4..044eef9 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 { replaceBotUsername } from '../utils/botUtils.js'; import { t } from '../i18n/index.js'; @@ -34,8 +35,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); @@ -43,7 +45,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); @@ -54,7 +56,7 @@ export class Bot { const listParticipantsHandler = new ListParticipantsCommandHandler(rideService, messageFormatter, rideMessagesService); const duplicateRideHandler = new DuplicateRideCommandHandler(rideService, messageFormatter, this.wizard, rideMessagesService); const resumeRideHandler = new ResumeRideCommandHandler(rideService, messageFormatter, rideMessagesService); - const participationHandler = new ParticipationHandlers(rideService, messageFormatter, rideMessagesService); + const participationHandler = new ParticipationHandlers(rideService, messageFormatter, rideMessagesService, notificationService); const shareRideHandler = new ShareRideCommandHandler(rideService, messageFormatter, rideMessagesService); diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index 5806c73..8491d71 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -277,6 +277,11 @@ Click here to start a private chat: @botname skipped: 'skipped' } }, + notifications: { + joined: '🚴 {name} joined your ride "{title}"', + thinking: '🤔 {name} is thinking about your ride "{title}"', + skipped: '🙅 {name} declined your ride "{title}"' + }, 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.' @@ -376,7 +381,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', @@ -397,7 +403,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' } } }, @@ -424,6 +431,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 ed8ceff..b82b2df 100644 --- a/src/i18n/locales/ru.js +++ b/src/i18n/locales/ru.js @@ -277,6 +277,11 @@ id: abc123 (or #abc123) skipped: 'пропускаю' } }, + notifications: { + joined: '🚴 {name} присоединился к вашей поездке "{title}"', + thinking: '🤔 {name} думает о вашей поездке "{title}"', + skipped: '🙅 {name} отказался от вашей поездки "{title}"' + }, stateChange: { onlyCreator: 'Только создатель поездки может {action} эту поездку.', messageUpdateError: 'Поездка была {action}, но возникла ошибка при обновлении сообщения о поездке. Возможно, нужно создать новое сообщение о поездке.' @@ -376,7 +381,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: 'Название не может быть пустым', @@ -397,7 +403,8 @@ id: abc123 (or #abc123) duration: '⏱ Длительность', speed: '🚴 Ср. скорость', meetingPoint: '📍 Место встречи', - additionalInfo: 'ℹ️ Дополнительно' + additionalInfo: 'ℹ️ Дополнительно', + notify: '🔔 Уведомления об участниках' } } }, @@ -424,6 +431,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..3f83e76 --- /dev/null +++ b/src/services/NotificationService.js @@ -0,0 +1,75 @@ +import { config } from '../config.js'; +import { t } from '../i18n/index.js'; + +const DEBOUNCE_DELAY_MS = 30_000; + +/** + * Service for sending debounced participation notifications to ride creators. + * When a participant's status changes, a 30-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, participant: Object, state: string}>} */ + this.pendingTimers = new Map(); + } + + /** + * Schedule a participation notification with debouncing. + * @param {import('../storage/interface.js').Ride} ride + * @param {Object} participant - Participant data + * @param {string} newState - New participation state ('joined'|'thinking'|'skipped') + * @param {Object} api - Grammy bot API object + */ + scheduleParticipationNotification(ride, participant, newState, api) { + if (ride.notifyOnParticipation === false) return; + if (ride.createdBy === participant.userId) return; + + const key = `${ride.id}:${participant.userId}`; + const existing = this.pendingTimers.get(key); + if (existing) { + clearTimeout(existing.timer); + } + + const timer = setTimeout(async () => { + this.pendingTimers.delete(key); + await this._sendNotification(ride, participant, newState, api); + }, DEBOUNCE_DELAY_MS); + + this.pendingTimers.set(key, { timer, participant, state: newState }); + } + + /** + * Send the participation notification DM to the ride creator. + * @param {import('../storage/interface.js').Ride} ride + * @param {Object} participant + * @param {string} state + * @param {Object} api + */ + async _sendNotification(ride, participant, state, api) { + try { + const language = config.i18n.defaultLanguage; + const name = this._formatName(participant); + const text = t(language, `commands.notifications.${state}`, { + name, + title: ride.title + }, { + fallbackLanguage: config.i18n.fallbackLanguage + }); + await api.sendMessage(ride.createdBy, text, { parse_mode: 'HTML' }); + } catch (err) { + console.error('NotificationService: failed to send notification:', err); + } + } + + /** + * Format participant display name. + * @param {Object} p - Participant object + * @returns {string} + */ + _formatName(p) { + const full = `${p.firstName || ''} ${p.lastName || ''}`.trim() || p.username || 'Someone'; + return p.username ? `${full} (@${p.username})` : full; + } +} diff --git a/src/services/RideService.js b/src/services/RideService.js index 04bf806..ae2c4e8 100644 --- a/src/services/RideService.js +++ b/src/services/RideService.js @@ -337,6 +337,11 @@ export class RideService { } } + // Copy notify preference from original ride if not explicitly provided + if (params.notify === undefined && originalRide.notifyOnParticipation !== undefined) { + mergedParams.notify = originalRide.notifyOnParticipation ? 'yes' : 'no'; + } + // Use existing createRideFromParams to handle all the validation and processing return await this.createRideFromParams(mergedParams, null, user, { language }); } diff --git a/src/storage/interface.js b/src/storage/interface.js index 4da4d0a..85b1019 100644 --- a/src/storage/interface.js +++ b/src/storage/interface.js @@ -20,6 +20,7 @@ * @property {number} [speedMax] * @property {string} [additionalInfo] * @property {boolean} [cancelled] + * @property {boolean} [notifyOnParticipation] * @property {Participation} participation - User participation in different states * @property {Date} createdAt * @property {number} createdBy diff --git a/src/storage/mongodb.js b/src/storage/mongodb.js index 7317766..3cf1b14 100644 --- a/src/storage/mongodb.js +++ b/src/storage/mongodb.js @@ -37,6 +37,7 @@ const rideSchema = new mongoose.Schema({ speedMax: Number, additionalInfo: String, cancelled: { type: Boolean, default: false }, + notifyOnParticipation: { type: Boolean, default: true }, createdAt: { type: Date, default: Date.now }, createdBy: { type: Number, required: true }, organizer: { type: String }, @@ -230,6 +231,7 @@ export class MongoDBStorage extends StorageInterface { speedMax: rideObj.speedMax, additionalInfo: rideObj.additionalInfo, cancelled: rideObj.cancelled, + notifyOnParticipation: rideObj.notifyOnParticipation ?? true, createdAt: rideObj.createdAt, createdBy: rideObj.createdBy, organizer: rideObj.organizer, diff --git a/src/utils/FieldProcessor.js b/src/utils/FieldProcessor.js index 4d38a76..78f237d 100644 --- a/src/utils/FieldProcessor.js +++ b/src/utils/FieldProcessor.js @@ -62,7 +62,13 @@ export class FieldProcessor { // Process simple text fields this.processTextFields(params, result.data, isUpdate); - + + // Process notify preference + if (params.notify !== undefined) { + const v = String(params.notify).toLowerCase().trim(); + result.data.notifyOnParticipation = v === 'yes' || v === 'true' || v === '1'; + } + return result; } diff --git a/src/utils/RideParamsHelper.js b/src/utils/RideParamsHelper.js index df6d879..25132c3 100644 --- a/src/utils/RideParamsHelper.js +++ b/src/utils/RideParamsHelper.js @@ -25,6 +25,7 @@ export class RideParamsHelper { duration: translate('params.duration'), speed: translate('params.speed'), info: translate('params.info'), + notify: translate('params.notify'), id: translate('params.id') }; } diff --git a/src/wizard/RideWizard.js b/src/wizard/RideWizard.js index f7d3968..7ef91d0 100644 --- a/src/wizard/RideWizard.js +++ b/src/wizard/RideWizard.js @@ -66,7 +66,9 @@ export class RideWizard { currentUser: ctx.from.id, // Store message thread ID if present messageThreadId: ctx.message?.message_thread_id, - ...(prefillData || {}) // Merge prefilled data if provided + ...(prefillData || {}), // Merge prefilled data if provided + // Default notifyOnParticipation to true if not provided by prefillData + notifyOnParticipation: prefillData?.notifyOnParticipation ?? true }, isUpdate: prefillData?.isUpdate || false, // Flag to indicate if this is an update originalRideId: prefillData?.originalRideId, // Store original ride ID for updates @@ -111,6 +113,18 @@ export class RideWizard { } break; + case 'notifyYes': + state.data.notifyOnParticipation = true; + state.step = 'confirm'; + await this.sendWizardStep(ctx, true); + break; + + case 'notifyNo': + state.data.notifyOnParticipation = false; + state.step = 'confirm'; + await this.sendWizardStep(ctx, true); + break; + case 'back': // Navigate to previous step using field configuration const currentFieldConfig = getFieldConfig(state.step, ctx.lang); @@ -118,8 +132,8 @@ export class RideWizard { state.step = currentFieldConfig.previousStep; await this.sendWizardStep(ctx, true); } else if (state.step === 'confirm') { - // Special case: confirm step goes back to info - state.step = 'info'; + // Special case: confirm step goes back to notify + state.step = 'notify'; await this.sendWizardStep(ctx, true); } break; @@ -324,24 +338,29 @@ export class RideWizard { let message = ''; let keyboard = new InlineKeyboard(); + if (state.step === 'confirm') { + // Handle confirm step separately (special case) + return this.sendConfirmStep(ctx, state, edit); + } else if (state.step === 'notify') { + // Handle notify step separately (special case) + return this.sendNotifyStep(ctx, state, edit); + } + // Get field configuration const fieldConfig = getFieldConfig(state.step, ctx.lang); - + if (fieldConfig) { // Build message with current value message = fieldConfig.prompt; - + // Add current value if exists const currentValue = this.getCurrentValueDisplay(state, fieldConfig, ctx); if (currentValue) { message += `\n\n${this.translate(ctx, 'wizard.messages.currentValue')}: ${currentValue}`; } - + // Build keyboard based on field type keyboard = this.buildFieldKeyboard(state, fieldConfig, ctx); - } else if (state.step === 'confirm') { - // Handle confirm step separately (special case) - return this.sendConfirmStep(ctx, state, edit); } else { console.error(`Unknown wizard step: ${state.step}`); return; @@ -490,6 +509,32 @@ export class RideWizard { await this.sendOrEditMessage(ctx, state, message, keyboard, edit); } + /** + * Send notify step (participation notification preference) + * @param {Object} ctx - Grammy context + * @param {Object} state - Wizard state + * @param {boolean} edit - Whether to edit existing message + */ + async sendNotifyStep(ctx, state, edit) { + const currentValue = state.data.notifyOnParticipation !== false; + const currentLabel = currentValue + ? this.translate(ctx, 'common.yes') + : this.translate(ctx, 'common.no'); + + const notifyConfig = getFieldConfig('notify', ctx.lang); + const message = `${notifyConfig.prompt}\n\n${this.translate(ctx, 'wizard.messages.currentValue')}: ${currentLabel}`; + + const keyboard = new InlineKeyboard() + .text(this.translate(ctx, 'common.yes'), 'wizard:notifyYes') + .text(this.translate(ctx, 'common.no'), 'wizard:notifyNo') + .row() + .text(this.translate(ctx, 'buttons.back'), 'wizard:back') + .row() + .text(this.translate(ctx, 'buttons.cancel'), 'wizard:cancel'); + + await this.sendOrEditMessage(ctx, state, message, keyboard, edit); + } + /** * Send or edit wizard message * @param {Object} ctx - Grammy context diff --git a/src/wizard/wizardFieldConfig.js b/src/wizard/wizardFieldConfig.js index c5dd87b..a3afb9d 100644 --- a/src/wizard/wizardFieldConfig.js +++ b/src/wizard/wizardFieldConfig.js @@ -27,7 +27,8 @@ export const FieldType = { ROUTE: 'route', NUMBER: 'number', DURATION: 'duration', - SPEED: 'speed' + SPEED: 'speed', + BOOLEAN: 'boolean' }; function translate(language, key, params = {}) { @@ -248,9 +249,25 @@ export function getWizardFields(language = config.i18n.defaultLanguage) { required: false, clearable: true, skippable: true, - nextStep: 'confirm', + nextStep: 'notify', previousStep: 'meet', validator: (text) => ({ valid: true, value: text }) + }, + + notify: { + step: 'notify', + type: FieldType.BOOLEAN, + dataKey: 'notifyOnParticipation', + prompt: translate(language, 'wizard.prompts.notify'), + required: true, + clearable: false, + skippable: false, + nextStep: 'confirm', + previousStep: 'info', + options: [ + { label: translate(language, 'common.yes'), value: true }, + { label: translate(language, 'common.no'), value: false } + ] } }; } @@ -302,7 +319,8 @@ export function buildRideDataFromWizard(wizardData, metadata = {}) { duration: wizardData.duration, speedMin: wizardData.speedMin, speedMax: wizardData.speedMax, - additionalInfo: wizardData.additionalInfo + additionalInfo: wizardData.additionalInfo, + notifyOnParticipation: wizardData.notifyOnParticipation ?? true }; if (isUpdate) { @@ -385,6 +403,14 @@ function getConfirmationFields(language = config.i18n.defaultLanguage) { dataKey: 'additionalInfo', required: false, format: (value, htmlEscape) => htmlEscape(value) + }, + { + label: translate(language, 'wizard.confirm.labels.notify'), + dataKey: 'notifyOnParticipation', + required: true, + format: (value) => value !== false + ? translate(language, 'common.yes') + : translate(language, 'common.no') } ]; } From 151030619120126b6cf382da1e37393edaf5ac70 Mon Sep 17 00:00:00 2001 From: Valery Leontyev Date: Tue, 10 Mar 2026 22:23:20 +0100 Subject: [PATCH 2/5] docs: add participation notifications specification Co-Authored-By: Claude Sonnet 4.6 --- ...rticipation-notifications-specification.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/changes/participation-notifications-specification.md diff --git a/docs/changes/participation-notifications-specification.md b/docs/changes/participation-notifications-specification.md new file mode 100644 index 0000000..92caa5f --- /dev/null +++ b/docs/changes/participation-notifications-specification.md @@ -0,0 +1,177 @@ +# 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 30 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 30 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}"` | +| `thinking` | `🤔 {name} is thinking about your ride "{title}"` | +| `skipped` | `🙅 {name} declined your ride "{title}"` | + +**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 30-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 ~30s 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` + +| Key | EN | RU | +|-----|----|----| +| `joined` | `🚴 {name} joined your ride "{title}"` | `🚴 {name} присоединился к вашей поездке "{title}"` | +| `thinking` | `🤔 {name} is thinking about your ride "{title}"` | `🤔 {name} думает о вашей поездке "{title}"` | +| `skipped` | `🙅 {name} declined your ride "{title}"` | `🙅 {name} отказался от вашей поездки "{title}"` | + +### `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` | From e1b063f21e2bff5b31950d2698c7fea52d625b42 Mon Sep 17 00:00:00 2001 From: Valery Leontyev Date: Tue, 10 Mar 2026 22:49:38 +0100 Subject: [PATCH 3/5] Reduce debounce to 20s and add stop-notifications footer to DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change DEBOUNCE_DELAY_MS from 30s to 20s in NotificationService - Append a tappable code-block footer to all three notification templates (joined/thinking/skipped) in both EN and RU locales: "🔕 To stop notifications: /updateride #{rideId}\nnotify: no" - Pass rideId as a new template param in _sendNotification - Update notification-service tests: rename "30s" → "20s" and add rideId to tr() calls so expected strings match the new templates - Update spec doc: debounce interval, message templates, i18n table Co-Authored-By: Claude Sonnet 4.6 --- ...rticipation-notifications-specification.md | 22 ++++++++++--------- .../services/notification-service.test.js | 8 ++++--- src/i18n/locales/en.js | 6 ++--- src/i18n/locales/ru.js | 6 ++--- src/services/NotificationService.js | 7 +++--- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/docs/changes/participation-notifications-specification.md b/docs/changes/participation-notifications-specification.md index 92caa5f..587b340 100644 --- a/docs/changes/participation-notifications-specification.md +++ b/docs/changes/participation-notifications-specification.md @@ -2,7 +2,7 @@ ## 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 30 seconds, only one message is sent reflecting the final state. The preference is stored per-ride and defaults to **yes**. +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**. --- @@ -12,7 +12,7 @@ Ride creators can opt in to receive a private Telegram DM whenever a participant 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 30 seconds +5. After the ride is created, whenever a participant changes their status the creator receives a DM within 20 seconds --- @@ -84,9 +84,9 @@ Sent to `ride.createdBy` (the creator's Telegram user ID) as a private message. | State | Template (EN) | |-------|---------------| -| `joined` | `🚴 {name} joined your ride "{title}"` | -| `thinking` | `🤔 {name} is thinking about your ride "{title}"` | -| `skipped` | `🙅 {name} declined your ride "{title}"` | +| `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. @@ -98,11 +98,11 @@ Sent to `ride.createdBy` (the creator's Telegram user ID) as a private message. - 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 30-second timer starts fresh on each state change +- 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 ~30s after the last click, saying she declined. +**Example:** Alice clicks Join, then Thinking, then Skip within 10 seconds → only one DM arrives ~20s after the last click, saying she declined. --- @@ -124,11 +124,13 @@ Existing rides without this field are treated as `true` (`?? true` fallback; no ### `commands.notifications` +Each message includes a stop-notifications footer with a tappable code block. + | Key | EN | RU | |-----|----|----| -| `joined` | `🚴 {name} joined your ride "{title}"` | `🚴 {name} присоединился к вашей поездке "{title}"` | -| `thinking` | `🤔 {name} is thinking about your ride "{title}"` | `🤔 {name} думает о вашей поездке "{title}"` | -| `skipped` | `🙅 {name} declined your ride "{title}"` | `🙅 {name} отказался от вашей поездки "{title}"` | +| `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` diff --git a/src/__tests__/services/notification-service.test.js b/src/__tests__/services/notification-service.test.js index 3ec3d34..1aca124 100644 --- a/src/__tests__/services/notification-service.test.js +++ b/src/__tests__/services/notification-service.test.js @@ -37,7 +37,7 @@ describe('NotificationService', () => { }); describe('scheduleParticipationNotification', () => { - it('sends notification after 30s', async () => { + it('sends notification after 20s', async () => { service.scheduleParticipationNotification(ride, participant, 'joined', mockApi); expect(mockApi.sendMessage).not.toHaveBeenCalled(); @@ -63,7 +63,8 @@ describe('NotificationService', () => { // 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 + title: ride.title, + rideId: ride.id }); expect(sentText).toBe(expectedText); }); @@ -105,7 +106,8 @@ describe('NotificationService', () => { 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 + title: ride.title, + rideId: ride.id }); expect(sentText).toBe(expectedText); } diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index 62c0388..b6155f9 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -285,9 +285,9 @@ Click here to start a private chat: @botname } }, notifications: { - joined: '🚴 {name} joined your ride "{title}"', - thinking: '🤔 {name} is thinking about your ride "{title}"', - skipped: '🙅 {name} declined your ride "{title}"' + 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.', diff --git a/src/i18n/locales/ru.js b/src/i18n/locales/ru.js index a2819e9..27ef875 100644 --- a/src/i18n/locales/ru.js +++ b/src/i18n/locales/ru.js @@ -285,9 +285,9 @@ id: abc123 (or #abc123) } }, notifications: { - joined: '🚴 {name} присоединился к вашей поездке "{title}"', - thinking: '🤔 {name} думает о вашей поездке "{title}"', - skipped: '🙅 {name} отказался от вашей поездки "{title}"' + 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} эту поездку.', diff --git a/src/services/NotificationService.js b/src/services/NotificationService.js index 3f83e76..5b1bd9a 100644 --- a/src/services/NotificationService.js +++ b/src/services/NotificationService.js @@ -1,11 +1,11 @@ import { config } from '../config.js'; import { t } from '../i18n/index.js'; -const DEBOUNCE_DELAY_MS = 30_000; +const DEBOUNCE_DELAY_MS = 20_000; /** * Service for sending debounced participation notifications to ride creators. - * When a participant's status changes, a 30-second timer is started before + * 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. */ @@ -53,7 +53,8 @@ export class NotificationService { const name = this._formatName(participant); const text = t(language, `commands.notifications.${state}`, { name, - title: ride.title + title: ride.title, + rideId: ride.id }, { fallbackLanguage: config.i18n.fallbackLanguage }); From 75cfcd0fb257dd5261ffd904ce8ff3cba1d3be53 Mon Sep 17 00:00:00 2001 From: Valery Leontyev Date: Tue, 10 Mar 2026 23:00:50 +0100 Subject: [PATCH 4/5] Silently ignore text input on button-only wizard steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the wizard is on a BOOLEAN step (e.g. notify), which has no text validator, any text message from the user caused an unhandled TypeError that was caught and logged as an error. This is a normal user action (typing instead of pressing a button) and should not produce an error log. Fix: check typeof fieldConfig.validator before calling it. If missing, delete the user's message silently and return — the step prompt stays unchanged so the user can click the correct button. Add regression test: text input on notify step must leave step unchanged and not throw. Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/wizard/ride-wizard.test.js | 14 ++++++++++++++ src/wizard/RideWizard.js | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/__tests__/wizard/ride-wizard.test.js b/src/__tests__/wizard/ride-wizard.test.js index 9889283..ead1644 100644 --- a/src/__tests__/wizard/ride-wizard.test.js +++ b/src/__tests__/wizard/ride-wizard.test.js @@ -766,5 +766,19 @@ describe.each(['en', 'ru'])('RideWizard (%s)', (language) => { 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/wizard/RideWizard.js b/src/wizard/RideWizard.js index 7ef91d0..5f2bd1f 100644 --- a/src/wizard/RideWizard.js +++ b/src/wizard/RideWizard.js @@ -250,6 +250,16 @@ export class RideWizard { return; } + // If the step has no text validator (e.g. button-only BOOLEAN steps), + // silently delete the user's message and leave the prompt unchanged. + if (typeof fieldConfig.validator !== 'function') { + try { + await ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id); + } catch (e) { /* ignore if already deleted */ } + state.errorMessageIds.pop(); // already handled above — remove from batch + return; + } + // Handle dash (-) for clearable fields if (fieldConfig.clearable && ctx.message.text === '-') { this.clearFieldValue(state, fieldConfig); From 0240ff0abff09e68c8652e78da9f2830789004f1 Mon Sep 17 00:00:00 2001 From: Valery Leontyev Date: Tue, 10 Mar 2026 23:09:55 +0100 Subject: [PATCH 5/5] Refactor _formatName for cleaner branch separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the implicit fallback chain with explicit branches: - firstName or lastName present → build full name, append username in parens if available - username only → 'username (@username)' - nothing → 'Someone' Co-Authored-By: Claude Sonnet 4.6 --- src/services/NotificationService.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/services/NotificationService.js b/src/services/NotificationService.js index 5b1bd9a..8806922 100644 --- a/src/services/NotificationService.js +++ b/src/services/NotificationService.js @@ -70,7 +70,13 @@ export class NotificationService { * @returns {string} */ _formatName(p) { - const full = `${p.firstName || ''} ${p.lastName || ''}`.trim() || p.username || 'Someone'; - return p.username ? `${full} (@${p.username})` : full; + if (p.firstName || p.lastName) { + const full = `${p.firstName || ''} ${p.lastName || ''}`.trim(); + return p.username ? `${full} (@${p.username})` : full; + } + if (p.username) { + return `${p.username} (@${p.username})`; + } + return 'Someone'; } }