diff --git a/apps/bot/src/ad-hoc-notifications.test.ts b/apps/bot/src/ad-hoc-notifications.test.ts index 6b726da..69465a5 100644 --- a/apps/bot/src/ad-hoc-notifications.test.ts +++ b/apps/bot/src/ad-hoc-notifications.test.ts @@ -258,6 +258,24 @@ describe('registerAdHocNotifications', () => { }, async interpretDraftEdit() { draftEditCalls += 1 + if (draftEditCalls === 2) { + return { + decision: 'cancel', + notificationText: null, + assigneeChanged: false, + assigneeMemberId: null, + resolvedLocalDate: null, + resolvedHour: null, + resolvedMinute: null, + resolutionMode: null, + deliveryMode: null, + dmRecipientMemberIds: null, + clarificationQuestion: null, + confidence: 95, + parserMode: 'llm' + } + } + return { decision: 'updated', notificationText: null, @@ -352,8 +370,7 @@ describe('registerAdHocNotifications', () => { ) const pending = await promptRepository.getPendingAction('-10012345', '10002') - const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId - expect(proposalId).toBeTruthy() + expect((pending?.payload as { proposalId?: string } | null)?.proposalId).toBeTruthy() await bot.handleUpdate(reminderMessageUpdate('Давай на 10 часов лучше') as never) @@ -362,12 +379,34 @@ describe('registerAdHocNotifications', () => { text: `Окей, ${updatedWhen} напомню.` }) - await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never) + await bot.handleUpdate(reminderMessageUpdate('А вообще, я не буду кушать') as never) - expect(calls[2]?.method).toBe('answerCallbackQuery') - expect(calls[3]?.method).toBe('editMessageText') - expect(calls[3]?.payload).toMatchObject({ - text: `Окей, ${updatedWhen} напомню.` + expect(draftEditCalls).toBe(2) + expect(calls[2]?.payload).toMatchObject({ + text: 'Окей, тогда не напоминаю.' + }) + expect(await promptRepository.getPendingAction('-10012345', '10002')).toBeNull() + + const replacementPending = await promptRepository.getPendingAction('-10012345', '10002') + expect(replacementPending).toBeNull() + + await bot.handleUpdate( + reminderMessageUpdate('Железяка, напомни пошпынять Георгия завтра с утра') as never + ) + + const renewedPending = await promptRepository.getPendingAction('-10012345', '10002') + const renewedProposalId = (renewedPending?.payload as { proposalId?: string } | null) + ?.proposalId + expect(renewedProposalId).toBeTruthy() + + await bot.handleUpdate( + reminderCallbackUpdate(`adhocnotif:confirm:${renewedProposalId}`) as never + ) + + expect(calls[4]?.method).toBe('answerCallbackQuery') + expect(calls[5]?.method).toBe('editMessageText') + expect(calls[5]?.payload).toMatchObject({ + text: `Окей, ${initialWhen} напомню.` }) expect(scheduledRequests).toEqual([ diff --git a/apps/bot/src/ad-hoc-notifications.ts b/apps/bot/src/ad-hoc-notifications.ts index d5d4fdf..1bf6e13 100644 --- a/apps/bot/src/ad-hoc-notifications.ts +++ b/apps/bot/src/ad-hoc-notifications.ts @@ -77,6 +77,10 @@ function unavailableReply(locale: BotLocale): string { : 'I cannot create reminders right now because the AI module is temporarily unavailable.' } +function cancelledDraftReply(locale: BotLocale): string { + return locale === 'ru' ? 'Окей, тогда не напоминаю.' : 'Okay, I will drop this reminder.' +} + function localNowText(timezone: string, now = nowInstant()): string { const local = now.toZonedDateTimeISO(timezone) return [ @@ -833,6 +837,15 @@ export function registerAdHocNotifications(options: { return } + if (interpretedEdit.decision === 'cancel') { + await options.promptRepository.clearPendingAction( + ctx.chat!.id.toString(), + ctx.from!.id.toString() + ) + await replyInTopic(ctx, cancelledDraftReply(reminderContext.locale)) + return + } + const scheduleChanged = interpretedEdit.resolvedLocalDate !== null || interpretedEdit.resolvedHour !== null || diff --git a/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts b/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts index 5025d5a..7196e73 100644 --- a/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts @@ -265,6 +265,55 @@ describe('createOpenAiAdHocNotificationInterpreter', () => { } }) + test('interprets draft edit cancellation requests', async () => { + const interpreter = createOpenAiAdHocNotificationInterpreter({ + apiKey: 'test-key', + parserModel: 'gpt-5-mini', + rendererModel: 'gpt-5-mini', + timeoutMs: 5000 + }) + expect(interpreter).toBeDefined() + + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + nestedJsonResponse({ + decision: 'cancel', + notificationText: null, + assigneeChanged: false, + assigneeMemberId: null, + resolvedLocalDate: null, + resolvedHour: null, + resolvedMinute: null, + resolutionMode: null, + deliveryMode: null, + dmRecipientMemberIds: null, + confidence: 95, + clarificationQuestion: null + })) as unknown as typeof fetch + + try { + const result = await interpreter!.interpretDraftEdit({ + locale: 'ru', + timezone: 'Asia/Tbilisi', + localNow: '2026-03-23 23:30', + text: 'А вообще, я не буду кушать', + members: [{ memberId: 'dima', displayName: 'Дима', status: 'active' }], + senderMemberId: 'dima', + currentNotificationText: 'покушать', + currentAssigneeMemberId: 'dima', + currentScheduledLocalDate: '2026-03-24', + currentScheduledHour: 11, + currentScheduledMinute: 0, + currentDeliveryMode: 'topic', + currentDmRecipientMemberIds: [] + }) + + expect(result?.decision).toBe('cancel') + } finally { + globalThis.fetch = originalFetch + } + }) + test('renders the final delivery text that should be persisted', async () => { const interpreter = createOpenAiAdHocNotificationInterpreter({ apiKey: 'test-key', diff --git a/apps/bot/src/openai-ad-hoc-notification-interpreter.ts b/apps/bot/src/openai-ad-hoc-notification-interpreter.ts index 66d696b..a93b39c 100644 --- a/apps/bot/src/openai-ad-hoc-notification-interpreter.ts +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.ts @@ -35,7 +35,7 @@ export interface AdHocNotificationScheduleInterpretation { } export interface AdHocNotificationDraftEditInterpretation { - decision: 'updated' | 'clarification' + decision: 'updated' | 'clarification' | 'cancel' notificationText: string | null assigneeChanged: boolean assigneeMemberId: string | null @@ -77,7 +77,7 @@ interface ReminderDeliveryTextResult { } interface ReminderDraftEditResult { - decision: 'updated' | 'clarification' + decision: 'updated' | 'clarification' | 'cancel' notificationText: string | null assigneeChanged: boolean assigneeMemberId: string | null @@ -520,7 +520,7 @@ export function createOpenAiAdHocNotificationInterpreter(input: { properties: { decision: { type: 'string', - enum: ['updated', 'clarification'] + enum: ['updated', 'clarification', 'cancel'] }, notificationText: { anyOf: [{ type: 'string' }, { type: 'null' }] @@ -596,6 +596,7 @@ export function createOpenAiAdHocNotificationInterpreter(input: { prompt: [ 'You interpret edit messages for an already prepared household reminder draft.', 'Treat the latest message as a request to modify the existing draft, not as a brand new reminder.', + 'If the user clearly says the reminder is no longer needed or should be dropped, return decision cancel.', 'Only return fields that should change; keep unchanged fields as null, except assigneeChanged must explicitly say whether the assignee should change.', 'Use notificationText only when the user changes what should be reminded.', 'Use deliveryMode when the user changes where the reminder should be sent.', @@ -630,7 +631,12 @@ export function createOpenAiAdHocNotificationInterpreter(input: { } return { - decision: parsed.decision === 'updated' ? 'updated' : 'clarification', + decision: + parsed.decision === 'updated' || + parsed.decision === 'clarification' || + parsed.decision === 'cancel' + ? parsed.decision + : 'clarification', notificationText: normalizeOptionalText(parsed.notificationText), assigneeChanged: parsed.assigneeChanged, assigneeMemberId: normalizeMemberId(parsed.assigneeMemberId, options.members),