fix(bot): support text cancellation of reminder drafts

This commit is contained in:
2026-03-24 04:20:51 +04:00
parent 782a8325ba
commit efc2e91bf6
4 changed files with 118 additions and 11 deletions

View File

@@ -258,6 +258,24 @@ describe('registerAdHocNotifications', () => {
}, },
async interpretDraftEdit() { async interpretDraftEdit() {
draftEditCalls += 1 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 { return {
decision: 'updated', decision: 'updated',
notificationText: null, notificationText: null,
@@ -352,8 +370,7 @@ describe('registerAdHocNotifications', () => {
) )
const pending = await promptRepository.getPendingAction('-10012345', '10002') const pending = await promptRepository.getPendingAction('-10012345', '10002')
const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId expect((pending?.payload as { proposalId?: string } | null)?.proposalId).toBeTruthy()
expect(proposalId).toBeTruthy()
await bot.handleUpdate(reminderMessageUpdate('Давай на 10 часов лучше') as never) await bot.handleUpdate(reminderMessageUpdate('Давай на 10 часов лучше') as never)
@@ -362,12 +379,34 @@ describe('registerAdHocNotifications', () => {
text: `Окей, ${updatedWhen} напомню.` text: `Окей, ${updatedWhen} напомню.`
}) })
await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never) await bot.handleUpdate(reminderMessageUpdate('А вообще, я не буду кушать') as never)
expect(calls[2]?.method).toBe('answerCallbackQuery') expect(draftEditCalls).toBe(2)
expect(calls[3]?.method).toBe('editMessageText') expect(calls[2]?.payload).toMatchObject({
expect(calls[3]?.payload).toMatchObject({ text: 'Окей, тогда не напоминаю.'
text: `Окей, ${updatedWhen} напомню.` })
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([ expect(scheduledRequests).toEqual([

View File

@@ -77,6 +77,10 @@ function unavailableReply(locale: BotLocale): string {
: 'I cannot create reminders right now because the AI module is temporarily unavailable.' : '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 { function localNowText(timezone: string, now = nowInstant()): string {
const local = now.toZonedDateTimeISO(timezone) const local = now.toZonedDateTimeISO(timezone)
return [ return [
@@ -833,6 +837,15 @@ export function registerAdHocNotifications(options: {
return 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 = const scheduleChanged =
interpretedEdit.resolvedLocalDate !== null || interpretedEdit.resolvedLocalDate !== null ||
interpretedEdit.resolvedHour !== null || interpretedEdit.resolvedHour !== null ||

View File

@@ -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 () => { test('renders the final delivery text that should be persisted', async () => {
const interpreter = createOpenAiAdHocNotificationInterpreter({ const interpreter = createOpenAiAdHocNotificationInterpreter({
apiKey: 'test-key', apiKey: 'test-key',

View File

@@ -35,7 +35,7 @@ export interface AdHocNotificationScheduleInterpretation {
} }
export interface AdHocNotificationDraftEditInterpretation { export interface AdHocNotificationDraftEditInterpretation {
decision: 'updated' | 'clarification' decision: 'updated' | 'clarification' | 'cancel'
notificationText: string | null notificationText: string | null
assigneeChanged: boolean assigneeChanged: boolean
assigneeMemberId: string | null assigneeMemberId: string | null
@@ -77,7 +77,7 @@ interface ReminderDeliveryTextResult {
} }
interface ReminderDraftEditResult { interface ReminderDraftEditResult {
decision: 'updated' | 'clarification' decision: 'updated' | 'clarification' | 'cancel'
notificationText: string | null notificationText: string | null
assigneeChanged: boolean assigneeChanged: boolean
assigneeMemberId: string | null assigneeMemberId: string | null
@@ -520,7 +520,7 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
properties: { properties: {
decision: { decision: {
type: 'string', type: 'string',
enum: ['updated', 'clarification'] enum: ['updated', 'clarification', 'cancel']
}, },
notificationText: { notificationText: {
anyOf: [{ type: 'string' }, { type: 'null' }] anyOf: [{ type: 'string' }, { type: 'null' }]
@@ -596,6 +596,7 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
prompt: [ prompt: [
'You interpret edit messages for an already prepared household reminder draft.', '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.', '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.', '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 notificationText only when the user changes what should be reminded.',
'Use deliveryMode when the user changes where the reminder should be sent.', 'Use deliveryMode when the user changes where the reminder should be sent.',
@@ -630,7 +631,12 @@ export function createOpenAiAdHocNotificationInterpreter(input: {
} }
return { return {
decision: parsed.decision === 'updated' ? 'updated' : 'clarification', decision:
parsed.decision === 'updated' ||
parsed.decision === 'clarification' ||
parsed.decision === 'cancel'
? parsed.decision
: 'clarification',
notificationText: normalizeOptionalText(parsed.notificationText), notificationText: normalizeOptionalText(parsed.notificationText),
assigneeChanged: parsed.assigneeChanged, assigneeChanged: parsed.assigneeChanged,
assigneeMemberId: normalizeMemberId(parsed.assigneeMemberId, options.members), assigneeMemberId: normalizeMemberId(parsed.assigneeMemberId, options.members),