diff --git a/apps/bot/src/ad-hoc-notifications.test.ts b/apps/bot/src/ad-hoc-notifications.test.ts index 3a66993..3827469 100644 --- a/apps/bot/src/ad-hoc-notifications.test.ts +++ b/apps/bot/src/ad-hoc-notifications.test.ts @@ -9,9 +9,10 @@ import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports' +import type { InlineKeyboardMarkup } from 'grammy/types' import { createTelegramBot } from './bot' -import { registerAdHocNotifications } from './ad-hoc-notifications' +import { formatReminderWhen, registerAdHocNotifications } from './ad-hoc-notifications' import type { AdHocNotificationInterpreter } from './openai-ad-hoc-notification-interpreter' function createPromptRepository(): TelegramPendingActionRepository { @@ -169,11 +170,33 @@ function createHouseholdRepository() { } describe('registerAdHocNotifications', () => { - test('shows the final rendered reminder text and persists that same text on confirm', async () => { + test('shows a compact playful confirmation, supports time edits, and persists the hidden rendered text on confirm', async () => { const bot = createTelegramBot('000000:test-token') const calls: Array<{ method: string; payload: unknown }> = [] const promptRepository = createPromptRepository() const scheduledRequests: Array<{ notificationText: string }> = [] + const now = Temporal.Now.instant() + const localNow = now.toZonedDateTimeISO('Asia/Tbilisi') + const baseDate = + localNow.hour <= 4 ? localNow.toPlainDate().subtract({ days: 1 }) : localNow.toPlainDate() + const tomorrow = baseDate.add({ days: 1 }).toString() + let draftEditCalls = 0 + const initialWhen = formatReminderWhen({ + locale: 'ru', + scheduledForIso: Temporal.ZonedDateTime.from(`${tomorrow}T09:00:00[Asia/Tbilisi]`) + .toInstant() + .toString(), + timezone: 'Asia/Tbilisi', + now + }) + const updatedWhen = formatReminderWhen({ + locale: 'ru', + scheduledForIso: Temporal.ZonedDateTime.from(`${tomorrow}T10:00:00[Asia/Tbilisi]`) + .toInstant() + .toString(), + timezone: 'Asia/Tbilisi', + now + }) bot.botInfo = { id: 999000, @@ -212,7 +235,7 @@ describe('registerAdHocNotifications', () => { decision: 'notification', notificationText: 'пошпынять Георгия о том, позвонил ли он', assigneeMemberId: 'georgiy', - resolvedLocalDate: '2026-03-24', + resolvedLocalDate: tomorrow, resolvedHour: 9, resolvedMinute: 0, resolutionMode: 'fuzzy_window', @@ -224,7 +247,7 @@ describe('registerAdHocNotifications', () => { async interpretSchedule() { return { decision: 'parsed', - resolvedLocalDate: '2026-03-24', + resolvedLocalDate: tomorrow, resolvedHour: 9, resolvedMinute: 0, resolutionMode: 'fuzzy_window', @@ -233,6 +256,24 @@ describe('registerAdHocNotifications', () => { parserMode: 'llm' } }, + async interpretDraftEdit() { + draftEditCalls += 1 + return { + decision: 'updated', + notificationText: null, + assigneeChanged: false, + assigneeMemberId: null, + resolvedLocalDate: tomorrow, + resolvedHour: 10, + resolvedMinute: 0, + resolutionMode: 'exact', + deliveryMode: null, + dmRecipientMemberIds: null, + clarificationQuestion: null, + confidence: 90, + parserMode: 'llm' + } + }, async renderDeliveryText(input) { expect(input.requesterDisplayName).toBe('Дима') expect(input.assigneeDisplayName).toBe('Георгий') @@ -301,15 +342,31 @@ describe('registerAdHocNotifications', () => { expect(calls[0]?.method).toBe('sendMessage') expect(calls[0]?.payload).toMatchObject({ - text: expect.stringContaining('Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.') + text: `Окей, ${initialWhen} напомню.` }) + expect((calls[0]?.payload as { text?: string })?.text).not.toContain( + 'Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.' + ) const pending = await promptRepository.getPendingAction('-10012345', '10002') const proposalId = (pending?.payload as { proposalId?: string } | null)?.proposalId expect(proposalId).toBeTruthy() + await bot.handleUpdate(reminderMessageUpdate('Давай на 10 часов лучше') as never) + + expect(draftEditCalls).toBe(1) + expect(calls[1]?.payload).toMatchObject({ + text: `Окей, ${updatedWhen} напомню.` + }) + await bot.handleUpdate(reminderCallbackUpdate(`adhocnotif:confirm:${proposalId}`) as never) + expect(calls[2]?.method).toBe('answerCallbackQuery') + expect(calls[3]?.method).toBe('editMessageText') + expect(calls[3]?.payload).toMatchObject({ + text: `Окей, ${updatedWhen} напомню.` + }) + expect(scheduledRequests).toEqual([ { notificationText: 'Дима, пора пошпынять Георгия и узнать, позвонил ли он уже.' @@ -378,4 +435,136 @@ describe('registerAdHocNotifications', () => { text: 'Сейчас не могу создать напоминание: модуль ИИ временно недоступен.' }) }) + + test('expands advanced controls inline', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const now = Temporal.Now.instant() + const localNow = now.toZonedDateTimeISO('Asia/Tbilisi') + const baseDate = + localNow.hour <= 4 ? localNow.toPlainDate().subtract({ days: 1 }) : localNow.toPlainDate() + const tomorrow = baseDate.add({ days: 1 }).toString() + const expectedWhen = formatReminderWhen({ + locale: 'ru', + scheduledForIso: Temporal.ZonedDateTime.from(`${tomorrow}T09:00:00[Asia/Tbilisi]`) + .toInstant() + .toString(), + timezone: 'Asia/Tbilisi', + now + }) + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -10012345, + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + registerAdHocNotifications({ + bot, + householdConfigurationRepository: createHouseholdRepository() as never, + promptRepository: createPromptRepository(), + notificationService: { + async scheduleNotification() { + throw new Error('not used') + }, + async listUpcomingNotifications() { + return [] + }, + async cancelNotification() { + return { status: 'not_found' } + }, + async listDueNotifications() { + return [] + }, + async claimDueNotification() { + return false + }, + async releaseDueNotification() {}, + async markNotificationSent() { + return null + } + }, + reminderInterpreter: { + async interpretRequest() { + return { + decision: 'notification', + notificationText: 'покушать', + assigneeMemberId: 'dima', + resolvedLocalDate: tomorrow, + resolvedHour: 9, + resolvedMinute: 0, + resolutionMode: 'fuzzy_window', + clarificationQuestion: null, + confidence: 90, + parserMode: 'llm' + } + }, + async interpretSchedule() { + throw new Error('not used') + }, + async interpretDraftEdit() { + throw new Error('not used') + }, + async renderDeliveryText() { + return 'Стас, не забудь покушать.' + } + } + }) + + await bot.handleUpdate(reminderMessageUpdate('Напомни завтра с утра покушать') as never) + + const firstPayload = calls[0]?.payload as { reply_markup?: InlineKeyboardMarkup; text?: string } + const moreButton = firstPayload.reply_markup?.inline_keyboard[0]?.[2] as + | { text?: string; callback_data?: string } + | undefined + expect(moreButton?.text).toBe('Еще') + expect(firstPayload.text).toBe(`Окей, ${expectedWhen} напомню.`) + + const callbackData = moreButton?.callback_data + expect(callbackData).toBeTruthy() + + await bot.handleUpdate(reminderCallbackUpdate(callbackData ?? 'missing') as never) + + expect(calls[1]?.method).toBe('editMessageText') + const expandedPayload = calls[1]?.payload as { reply_markup?: InlineKeyboardMarkup } + expect(expandedPayload.reply_markup?.inline_keyboard[0]?.[2]?.text).toBe('Скрыть') + expect(expandedPayload.reply_markup?.inline_keyboard[1]?.[0]?.text).toContain('В топик') + }) +}) + +describe('formatReminderWhen', () => { + test('uses sleep-aware tomorrow wording for the upcoming morning', () => { + expect( + formatReminderWhen({ + locale: 'ru', + scheduledForIso: '2026-03-24T05:00:00Z', + timezone: 'Asia/Tbilisi', + now: Temporal.Instant.from('2026-03-23T21:00:00Z') + }) + ).toBe('завтра в 9 утра') + }) }) diff --git a/apps/bot/src/ad-hoc-notifications.ts b/apps/bot/src/ad-hoc-notifications.ts index 5fe9195..78b5698 100644 --- a/apps/bot/src/ad-hoc-notifications.ts +++ b/apps/bot/src/ad-hoc-notifications.ts @@ -26,6 +26,7 @@ const AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX = 'adhocnotif:canceldraft:' const AD_HOC_NOTIFICATION_CANCEL_SAVED_PREFIX = 'adhocnotif:cancel:' const AD_HOC_NOTIFICATION_MODE_PREFIX = 'adhocnotif:mode:' const AD_HOC_NOTIFICATION_MEMBER_PREFIX = 'adhocnotif:member:' +const AD_HOC_NOTIFICATION_VIEW_PREFIX = 'adhocnotif:view:' type NotificationDraftPayload = | { @@ -56,6 +57,7 @@ type NotificationDraftPayload = timePrecision: 'exact' | 'date_only_defaulted' deliveryMode: AdHocNotificationDeliveryMode dmRecipientMemberIds: readonly string[] + viewMode: 'compact' | 'expanded' } interface ReminderTopicContext { @@ -139,6 +141,82 @@ function formatScheduledFor(locale: BotLocale, scheduledForIso: string, timezone return `${date} ${time} (${timezone})` } +function formatTimeOfDay(locale: BotLocale, hour: number, minute: number): string { + const exact = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` + if (locale !== 'ru' || minute !== 0) { + return locale === 'ru' ? `в ${exact}` : `at ${exact}` + } + + if (hour >= 5 && hour <= 11) { + return `в ${hour} утра` + } + if (hour >= 12 && hour <= 16) { + return hour === 12 ? 'в 12 дня' : `в ${hour} дня` + } + if (hour >= 17 && hour <= 23) { + return hour === 18 ? 'в 6 вечера' : `в ${hour > 12 ? hour - 12 : hour} вечера` + } + + return `в ${hour} ночи` +} + +function relativeDayLabel(input: { + locale: BotLocale + now: Temporal.ZonedDateTime + target: Temporal.ZonedDateTime +}): string | null { + const targetDate = input.target.toPlainDate() + const nowDate = input.now.toPlainDate() + const tomorrow = nowDate.add({ days: 1 }) + const dayAfterTomorrow = nowDate.add({ days: 2 }) + + const sleepAwareCurrentDate = input.now.hour <= 4 ? nowDate.subtract({ days: 1 }) : nowDate + const sleepAwareTomorrow = sleepAwareCurrentDate.add({ days: 1 }) + const sleepAwareDayAfterTomorrow = sleepAwareCurrentDate.add({ days: 2 }) + + if (targetDate.equals(sleepAwareCurrentDate)) { + return input.locale === 'ru' ? 'сегодня' : 'today' + } + if (targetDate.equals(sleepAwareTomorrow)) { + return input.locale === 'ru' ? 'завтра' : 'tomorrow' + } + if (targetDate.equals(sleepAwareDayAfterTomorrow)) { + return input.locale === 'ru' ? 'послезавтра' : 'the day after tomorrow' + } + if (targetDate.equals(tomorrow)) { + return input.locale === 'ru' ? 'завтра' : 'tomorrow' + } + if (targetDate.equals(dayAfterTomorrow)) { + return input.locale === 'ru' ? 'послезавтра' : 'the day after tomorrow' + } + + return null +} + +export function formatReminderWhen(input: { + locale: BotLocale + scheduledForIso: string + timezone: string + now?: Temporal.Instant +}): string { + const now = (input.now ?? nowInstant()).toZonedDateTimeISO(input.timezone) + const target = Temporal.Instant.from(input.scheduledForIso).toZonedDateTimeISO(input.timezone) + const relativeDay = relativeDayLabel({ + locale: input.locale, + now, + target + }) + const timeText = formatTimeOfDay(input.locale, target.hour, target.minute) + + if (relativeDay) { + return input.locale === 'ru' ? `${relativeDay} ${timeText}` : `${relativeDay} ${timeText}` + } + + return input.locale === 'ru' + ? `${formatScheduledFor(input.locale, input.scheduledForIso, input.timezone)}` + : formatScheduledFor(input.locale, input.scheduledForIso, input.timezone) +} + function deliveryModeLabel(locale: BotLocale, mode: AdHocNotificationDeliveryMode): string { if (locale === 'ru') { switch (mode) { @@ -166,49 +244,49 @@ function notificationSummaryText(input: { payload: Extract members: readonly HouseholdMemberRecord[] }): string { - const assignee = input.payload.assigneeMemberId - ? input.members.find((member) => member.id === input.payload.assigneeMemberId) - : null - const selectedRecipients = - input.payload.deliveryMode === 'dm_selected' - ? input.members.filter((member) => input.payload.dmRecipientMemberIds.includes(member.id)) - : [] - if (input.locale === 'ru') { - return [ - 'Запланировать напоминание?', - '', - `Текст напоминания: ${input.payload.renderedNotificationText}`, - `Когда: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, - `Точность: ${input.payload.timePrecision === 'date_only_defaulted' ? 'время определено ботом' : 'точное время'}`, - `Куда: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, - assignee ? `Ответственный: ${assignee.displayName}` : null, - input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 - ? `Получатели: ${selectedRecipients.map((member) => member.displayName).join(', ')}` - : null, - '', - 'Подтвердите или измените настройки ниже.' - ] - .filter(Boolean) - .join('\n') + const base = `Окей, ${formatReminderWhen({ + locale: input.locale, + scheduledForIso: input.payload.scheduledForIso, + timezone: input.payload.timezone + })} напомню.` + if (input.payload.deliveryMode === 'topic') { + return base + } + if (input.payload.deliveryMode === 'dm_all') { + return `${base.slice(0, -1)} И всем в личку отправлю.` + } + + const selectedRecipients = input.members.filter((member) => + input.payload.dmRecipientMemberIds.includes(member.id) + ) + const suffix = + selectedRecipients.length > 0 + ? ` И выбранным в личку отправлю: ${selectedRecipients.map((member) => member.displayName).join(', ')}.` + : ' И выбранным в личку отправлю.' + return `${base.slice(0, -1)}${suffix}` } - return [ - 'Schedule this notification?', - '', - `Reminder text: ${input.payload.renderedNotificationText}`, - `When: ${formatScheduledFor(input.locale, input.payload.scheduledForIso, input.payload.timezone)}`, - `Precision: ${input.payload.timePrecision === 'date_only_defaulted' ? 'inferred/defaulted time' : 'exact time'}`, - `Delivery: ${deliveryModeLabel(input.locale, input.payload.deliveryMode)}`, - assignee ? `Assignee: ${assignee.displayName}` : null, - input.payload.deliveryMode === 'dm_selected' && selectedRecipients.length > 0 - ? `Recipients: ${selectedRecipients.map((member) => member.displayName).join(', ')}` - : null, - '', - 'Confirm or adjust below.' - ] - .filter(Boolean) - .join('\n') + const base = `Okay, I’ll remind ${formatReminderWhen({ + locale: input.locale, + scheduledForIso: input.payload.scheduledForIso, + timezone: input.payload.timezone + })}.` + if (input.payload.deliveryMode === 'topic') { + return base + } + if (input.payload.deliveryMode === 'dm_all') { + return `${base.slice(0, -1)} and DM everyone too.` + } + + const selectedRecipients = input.members.filter((member) => + input.payload.dmRecipientMemberIds.includes(member.id) + ) + const suffix = + selectedRecipients.length > 0 + ? ` and DM the selected people too: ${selectedRecipients.map((member) => member.displayName).join(', ')}.` + : ' and DM the selected people too.' + return `${base.slice(0, -1)}${suffix}` } function notificationDraftReplyMarkup( @@ -216,6 +294,27 @@ function notificationDraftReplyMarkup( payload: Extract, members: readonly HouseholdMemberRecord[] ): InlineKeyboardMarkup { + if (payload.viewMode === 'compact') { + return { + inline_keyboard: [ + [ + { + text: locale === 'ru' ? 'Подтвердить' : 'Confirm', + callback_data: `${AD_HOC_NOTIFICATION_CONFIRM_PREFIX}${payload.proposalId}` + }, + { + text: locale === 'ru' ? 'Отменить' : 'Cancel', + callback_data: `${AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX}${payload.proposalId}` + }, + { + text: locale === 'ru' ? 'Еще' : 'More', + callback_data: `${AD_HOC_NOTIFICATION_VIEW_PREFIX}${payload.proposalId}:expanded` + } + ] + ] + } + } + const deliveryButtons = [ { text: `${payload.deliveryMode === 'topic' ? '• ' : ''}${locale === 'ru' ? 'В топик' : 'Topic'}`, @@ -236,6 +335,10 @@ function notificationDraftReplyMarkup( { text: locale === 'ru' ? 'Отменить' : 'Cancel', callback_data: `${AD_HOC_NOTIFICATION_CANCEL_DRAFT_PREFIX}${payload.proposalId}` + }, + { + text: locale === 'ru' ? 'Скрыть' : 'Less', + callback_data: `${AD_HOC_NOTIFICATION_VIEW_PREFIX}${payload.proposalId}:compact` } ], deliveryButtons, @@ -416,6 +519,19 @@ async function loadDraft( : null } +function draftLocalSchedule(payload: Extract): { + date: string + hour: number + minute: number +} { + const zdt = Temporal.Instant.from(payload.scheduledForIso).toZonedDateTimeISO(payload.timezone) + return { + date: zdt.toPlainDate().toString(), + hour: zdt.hour, + minute: zdt.minute + } +} + export function registerAdHocNotifications(options: { bot: Bot householdConfigurationRepository: HouseholdConfigurationRepository @@ -643,14 +759,138 @@ export function registerAdHocNotifications(options: { stage: 'confirm', renderedNotificationText, scheduledForIso: schedule.scheduledFor!.toString(), - timePrecision: schedule.timePrecision! + timePrecision: schedule.timePrecision!, + viewMode: 'compact' } await saveDraft(options.promptRepository, ctx, confirmPayload) await showDraftConfirmation(ctx, confirmPayload) return } - await next() + if (!options.reminderInterpreter) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + + const currentSchedule = draftLocalSchedule(existingDraft) + const interpretedEdit = await options.reminderInterpreter.interpretDraftEdit({ + locale: reminderContext.locale, + timezone: existingDraft.timezone, + localNow: localNowText(existingDraft.timezone), + text: messageText, + members: interpreterMembers(reminderContext.members), + senderMemberId: reminderContext.member.id, + currentNotificationText: existingDraft.normalizedNotificationText, + currentAssigneeMemberId: existingDraft.assigneeMemberId, + currentScheduledLocalDate: currentSchedule.date, + currentScheduledHour: currentSchedule.hour, + currentScheduledMinute: currentSchedule.minute, + currentDeliveryMode: existingDraft.deliveryMode, + currentDmRecipientMemberIds: existingDraft.dmRecipientMemberIds, + assistantContext: reminderContext.assistantContext, + assistantTone: reminderContext.assistantTone + }) + + if (!interpretedEdit) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + + if (interpretedEdit.decision === 'clarification') { + await replyInTopic( + ctx, + interpretedEdit.clarificationQuestion ?? + (reminderContext.locale === 'ru' + ? 'Что именно поправить в напоминании?' + : 'What should I adjust in the reminder?') + ) + return + } + + const scheduleChanged = + interpretedEdit.resolvedLocalDate !== null || + interpretedEdit.resolvedHour !== null || + interpretedEdit.resolvedMinute !== null || + interpretedEdit.resolutionMode !== null + + let nextSchedule = { + scheduledForIso: existingDraft.scheduledForIso, + timePrecision: existingDraft.timePrecision + } + + if (scheduleChanged) { + const parsedSchedule = parseAdHocNotificationSchedule({ + timezone: existingDraft.timezone, + resolvedLocalDate: interpretedEdit.resolvedLocalDate ?? currentSchedule.date, + resolvedHour: interpretedEdit.resolvedHour ?? currentSchedule.hour, + resolvedMinute: interpretedEdit.resolvedMinute ?? currentSchedule.minute, + resolutionMode: interpretedEdit.resolutionMode ?? 'exact' + }) + + if (parsedSchedule.kind === 'missing_schedule') { + await replyInTopic( + ctx, + reminderContext.locale === 'ru' + ? 'Нужны понятные дата или время, чтобы обновить напоминание.' + : 'I need a clear date or time to update the reminder.' + ) + return + } + + if (parsedSchedule.kind === 'invalid_past') { + await replyInTopic( + ctx, + reminderContext.locale === 'ru' + ? 'Это время уже в прошлом. Пришлите будущую дату или время.' + : 'That time is already in the past. Send a future date or time.' + ) + return + } + + nextSchedule = { + scheduledForIso: parsedSchedule.scheduledFor!.toString(), + timePrecision: parsedSchedule.timePrecision! + } + } + + const nextNormalizedNotificationText = + interpretedEdit.notificationText ?? existingDraft.normalizedNotificationText + const nextOriginalRequestText = + interpretedEdit.notificationText !== null ? messageText : existingDraft.originalRequestText + const nextAssigneeMemberId = interpretedEdit.assigneeChanged + ? interpretedEdit.assigneeMemberId + : existingDraft.assigneeMemberId + const nextDeliveryMode = interpretedEdit.deliveryMode ?? existingDraft.deliveryMode + const nextDmRecipientMemberIds = + interpretedEdit.dmRecipientMemberIds ?? + (nextDeliveryMode === existingDraft.deliveryMode ? existingDraft.dmRecipientMemberIds : []) + + const renderedNotificationText = await renderNotificationText({ + reminderContext, + originalRequestText: nextOriginalRequestText, + normalizedNotificationText: nextNormalizedNotificationText, + assigneeMemberId: nextAssigneeMemberId + }) + if (!renderedNotificationText) { + await replyInTopic(ctx, unavailableReply(reminderContext.locale)) + return + } + + const nextPayload: Extract = { + ...existingDraft, + originalRequestText: nextOriginalRequestText, + normalizedNotificationText: nextNormalizedNotificationText, + renderedNotificationText, + assigneeMemberId: nextAssigneeMemberId, + scheduledForIso: nextSchedule.scheduledForIso, + timePrecision: nextSchedule.timePrecision, + deliveryMode: nextDeliveryMode, + dmRecipientMemberIds: nextDmRecipientMemberIds, + viewMode: 'compact' + } + + await saveDraft(options.promptRepository, ctx, nextPayload) + await showDraftConfirmation(ctx, nextPayload) return } @@ -771,7 +1011,8 @@ export function registerAdHocNotifications(options: { scheduledForIso: parsedSchedule.scheduledFor!.toString(), timePrecision: parsedSchedule.timePrecision!, deliveryMode: 'topic', - dmRecipientMemberIds: [] + dmRecipientMemberIds: [], + viewMode: 'compact' } await saveDraft(options.promptRepository, ctx, draft) @@ -839,16 +1080,11 @@ export function registerAdHocNotifications(options: { reminderContext.locale === 'ru' ? 'Напоминание запланировано.' : 'Notification scheduled.' }) await ctx.editMessageText( - [ - reminderContext.locale === 'ru' - ? `Напоминание запланировано: ${result.notification.notificationText}` - : `Notification scheduled: ${result.notification.notificationText}`, - formatScheduledFor( - reminderContext.locale, - result.notification.scheduledFor.toString(), - result.notification.timezone - ) - ].join('\n'), + notificationSummaryText({ + locale: reminderContext.locale, + payload, + members: reminderContext.members + }), { reply_markup: buildSavedNotificationReplyMarkup( reminderContext.locale, @@ -898,7 +1134,8 @@ export function registerAdHocNotifications(options: { const nextPayload: Extract = { ...payload, deliveryMode: mode, - dmRecipientMemberIds: mode === 'dm_selected' ? payload.dmRecipientMemberIds : [] + dmRecipientMemberIds: mode === 'dm_selected' ? payload.dmRecipientMemberIds : [], + viewMode: 'expanded' } await refreshConfirmationMessage(ctx, nextPayload) await ctx.answerCallbackQuery() @@ -930,7 +1167,29 @@ export function registerAdHocNotifications(options: { await refreshConfirmationMessage(ctx, { ...payload, - dmRecipientMemberIds: [...selected] + dmRecipientMemberIds: [...selected], + viewMode: 'expanded' + }) + await ctx.answerCallbackQuery() + return + } + + if (data.startsWith(AD_HOC_NOTIFICATION_VIEW_PREFIX)) { + const [proposalId, viewMode] = data.slice(AD_HOC_NOTIFICATION_VIEW_PREFIX.length).split(':') + const payload = await loadDraft(options.promptRepository, ctx) + if ( + !payload || + payload.stage !== 'confirm' || + payload.proposalId !== proposalId || + (viewMode !== 'compact' && viewMode !== 'expanded') + ) { + await next() + return + } + + await refreshConfirmationMessage(ctx, { + ...payload, + viewMode }) await ctx.answerCallbackQuery() return 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 5b9baf0..5025d5a 100644 --- a/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.test.ts @@ -206,6 +206,65 @@ describe('createOpenAiAdHocNotificationInterpreter', () => { } }) + test('interprets draft edits as partial updates', 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: 'updated', + notificationText: null, + assigneeChanged: false, + assigneeMemberId: null, + resolvedLocalDate: '2026-03-24', + resolvedHour: 10, + resolvedMinute: 0, + resolutionMode: 'exact', + deliveryMode: null, + dmRecipientMemberIds: null, + confidence: 88, + 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: 'Давай на 10 часов лучше', + members: [ + { memberId: 'dima', displayName: 'Дима', status: 'active' }, + { memberId: 'georgiy', displayName: 'Георгий', status: 'active' } + ], + senderMemberId: 'dima', + currentNotificationText: 'пошпынять Георгия о том, позвонил ли он', + currentAssigneeMemberId: 'georgiy', + currentScheduledLocalDate: '2026-03-24', + currentScheduledHour: 9, + currentScheduledMinute: 0, + currentDeliveryMode: 'topic', + currentDmRecipientMemberIds: [] + }) + + expect(result).toMatchObject({ + decision: 'updated', + resolvedHour: 10, + resolvedMinute: 0, + resolutionMode: 'exact', + notificationText: null, + deliveryMode: null + }) + } 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 8cbcea7..66d696b 100644 --- a/apps/bot/src/openai-ad-hoc-notification-interpreter.ts +++ b/apps/bot/src/openai-ad-hoc-notification-interpreter.ts @@ -1,3 +1,5 @@ +import type { AdHocNotificationDeliveryMode } from '@household/ports' + import { extractOpenAiResponseText, parseJsonFromResponseText } from './openai-responses' export type AdHocNotificationResolutionMode = 'exact' | 'fuzzy_window' | 'date_only' | 'ambiguous' @@ -32,6 +34,22 @@ export interface AdHocNotificationScheduleInterpretation { parserMode: 'llm' } +export interface AdHocNotificationDraftEditInterpretation { + decision: 'updated' | 'clarification' + notificationText: string | null + assigneeChanged: boolean + assigneeMemberId: string | null + resolvedLocalDate: string | null + resolvedHour: number | null + resolvedMinute: number | null + resolutionMode: AdHocNotificationResolutionMode | null + deliveryMode: AdHocNotificationDeliveryMode | null + dmRecipientMemberIds: readonly string[] | null + clarificationQuestion: string | null + confidence: number + parserMode: 'llm' +} + interface ReminderInterpretationResult { decision: 'notification' | 'clarification' | 'not_notification' notificationText: string | null @@ -58,6 +76,21 @@ interface ReminderDeliveryTextResult { text: string | null } +interface ReminderDraftEditResult { + decision: 'updated' | 'clarification' + notificationText: string | null + assigneeChanged: boolean + assigneeMemberId: string | null + resolvedLocalDate: string | null + resolvedHour: number | null + resolvedMinute: number | null + resolutionMode: AdHocNotificationResolutionMode | null + deliveryMode: AdHocNotificationDeliveryMode | null + dmRecipientMemberIds: string[] | null + confidence: number + clarificationQuestion: string | null +} + export interface AdHocNotificationInterpreter { interpretRequest(input: { locale: 'en' | 'ru' @@ -75,6 +108,23 @@ export interface AdHocNotificationInterpreter { localNow: string text: string }): Promise + interpretDraftEdit(input: { + locale: 'en' | 'ru' + timezone: string + localNow: string + text: string + members: readonly AdHocNotificationInterpreterMember[] + senderMemberId: string + currentNotificationText: string + currentAssigneeMemberId: string | null + currentScheduledLocalDate: string + currentScheduledHour: number + currentScheduledMinute: number + currentDeliveryMode: AdHocNotificationDeliveryMode + currentDmRecipientMemberIds: readonly string[] + assistantContext?: string | null + assistantTone?: string | null + }): Promise renderDeliveryText(input: { locale: 'en' | 'ru' originalRequestText: string @@ -86,6 +136,25 @@ export interface AdHocNotificationInterpreter { }): Promise } +function normalizeDeliveryMode( + value: string | null | undefined +): AdHocNotificationDeliveryMode | null { + return value === 'topic' || value === 'dm_all' || value === 'dm_selected' ? value : null +} + +function normalizeMemberIds( + value: readonly string[] | null | undefined, + members: readonly AdHocNotificationInterpreterMember[] +): readonly string[] | null { + if (value === null || value === undefined) { + return null + } + + const valid = new Set(members.map((member) => member.memberId)) + const selected = value.filter((memberId) => valid.has(memberId)) + return [...new Set(selected)] +} + function normalizeOptionalText(value: string | null | undefined): string | null { const trimmed = value?.trim() return trimmed && trimmed.length > 0 ? trimmed : null @@ -440,6 +509,143 @@ export function createOpenAiAdHocNotificationInterpreter(input: { } }, + async interpretDraftEdit(options) { + const parsed = await fetchStructuredResult({ + apiKey, + model: parserModel, + schemaName: 'ad_hoc_notification_draft_edit', + schema: { + type: 'object', + additionalProperties: false, + properties: { + decision: { + type: 'string', + enum: ['updated', 'clarification'] + }, + notificationText: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, + assigneeChanged: { + type: 'boolean' + }, + assigneeMemberId: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, + resolvedLocalDate: { + anyOf: [{ type: 'string' }, { type: 'null' }] + }, + resolvedHour: { + anyOf: [{ type: 'integer' }, { type: 'null' }] + }, + resolvedMinute: { + anyOf: [{ type: 'integer' }, { type: 'null' }] + }, + resolutionMode: { + anyOf: [ + { + type: 'string', + enum: ['exact', 'fuzzy_window', 'date_only', 'ambiguous'] + }, + { type: 'null' } + ] + }, + deliveryMode: { + anyOf: [ + { + type: 'string', + enum: ['topic', 'dm_all', 'dm_selected'] + }, + { type: 'null' } + ] + }, + dmRecipientMemberIds: { + anyOf: [ + { + type: 'array', + items: { + type: 'string' + } + }, + { type: 'null' } + ] + }, + confidence: { + type: 'number', + minimum: 0, + maximum: 100 + }, + clarificationQuestion: { + anyOf: [{ type: 'string' }, { type: 'null' }] + } + }, + required: [ + 'decision', + 'notificationText', + 'assigneeChanged', + 'assigneeMemberId', + 'resolvedLocalDate', + 'resolvedHour', + 'resolvedMinute', + 'resolutionMode', + 'deliveryMode', + 'dmRecipientMemberIds', + 'confidence', + 'clarificationQuestion' + ] + }, + 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.', + '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.', + 'Use dmRecipientMemberIds only when the user clearly changes selected DM recipients.', + 'Use resolutionMode exact for explicit clock time, fuzzy_window for phrases like morning/evening, date_only for plain day/date without explicit time, ambiguous when still unclear.', + 'If the latest message is too ambiguous, return clarification and a short clarificationQuestion in the user language.', + promptWindowRules(), + options.assistantContext ? `Household context: ${options.assistantContext}` : null, + options.assistantTone ? `Preferred tone: ${options.assistantTone}` : null, + `Household timezone: ${options.timezone}`, + `Current local date/time in that timezone: ${options.localNow}`, + rosterText(options.members, options.senderMemberId), + '', + 'Current draft:', + `- notificationText: ${options.currentNotificationText}`, + `- assigneeMemberId: ${options.currentAssigneeMemberId ?? 'none'}`, + `- scheduledLocalDate: ${options.currentScheduledLocalDate}`, + `- scheduledLocalTime: ${String(options.currentScheduledHour).padStart(2, '0')}:${String(options.currentScheduledMinute).padStart(2, '0')}`, + `- deliveryMode: ${options.currentDeliveryMode}`, + `- dmRecipientMemberIds: ${options.currentDmRecipientMemberIds.join(', ') || 'none'}`, + '', + 'Latest user edit message:', + options.text + ] + .filter(Boolean) + .join('\n'), + timeoutMs + }) + + if (!parsed) { + return null + } + + return { + decision: parsed.decision === 'updated' ? 'updated' : 'clarification', + notificationText: normalizeOptionalText(parsed.notificationText), + assigneeChanged: parsed.assigneeChanged, + assigneeMemberId: normalizeMemberId(parsed.assigneeMemberId, options.members), + resolvedLocalDate: normalizeOptionalText(parsed.resolvedLocalDate), + resolvedHour: normalizeHour(parsed.resolvedHour), + resolvedMinute: normalizeMinute(parsed.resolvedMinute), + resolutionMode: normalizeResolutionMode(parsed.resolutionMode), + deliveryMode: normalizeDeliveryMode(parsed.deliveryMode), + dmRecipientMemberIds: normalizeMemberIds(parsed.dmRecipientMemberIds, options.members), + clarificationQuestion: normalizeOptionalText(parsed.clarificationQuestion), + confidence: normalizeConfidence(parsed.confidence), + parserMode: 'llm' + } + }, + async renderDeliveryText(options) { const parsed = await fetchStructuredResult({ apiKey,