← Back to task

Commit 508eca39

commit 508eca399d9dc8be4135c549d9b55e3ae19a835e
Author: Ben Sima <ben@bensima.com>
Date:   Thu Jan 1 11:13:22 2026

    Add Complete/Snooze buttons to reminder notifications (t-292)
    
    When a reminder fires, the message now includes inline keyboard buttons:
    - ✅ Complete - marks the reminder as done
    - ⏰ 1h - snoozes for 1 hour
    - ⏰ 1d - snoozes for 1 day
    
    Implementation:
    - Reminders.hs: Added completeReminder and snoozeReminder functions
    - Actions.hs: Added reminder_complete, reminder_snooze_1h, reminder_snooze_1d actions
    - Messages.hs: Changed messageDispatchLoop to take ScheduledMessage-aware callback
    - Telegram.hs: Added makeMessageSendFn that detects reminder messages and
      sends them with inline keyboard buttons instead of plain text
    
    Task-Id: t-292

diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs
index a08eb530..5f9ea196 100644
--- a/Omni/Agent/Telegram.hs
+++ b/Omni/Agent/Telegram.hs
@@ -476,6 +476,38 @@ sendMessageWithKeyboard cfg chatId text keyboard = do
           _ -> pure Nothing
         _ -> pure Nothing
 
+-- | Create the send function for the message dispatch loop.
+-- This handles different message types, adding inline keyboards for reminders.
+makeMessageSendFn :: Types.TelegramConfig -> Messages.ScheduledMessage -> IO (Maybe Int)
+makeMessageSendFn cfg msg =
+  case Messages.smMessageType msg of
+    Just "reminder" -> sendReminderWithButtons cfg msg
+    _ -> sendMessageReturningId cfg (Messages.smChatId msg) (Messages.smThreadId msg) (Messages.smContent msg)
+
+-- | Send a reminder message with Complete/Snooze buttons
+sendReminderWithButtons :: Types.TelegramConfig -> Messages.ScheduledMessage -> IO (Maybe Int)
+sendReminderWithButtons cfg msg = do
+  -- Extract reminder ID from correlation_id (which we set when enqueueing)
+  let reminderId = fromMaybe "" (Messages.smCorrelationId msg)
+      -- Clean up the message content (remove the [reminder_id:X] tag if present)
+      content = cleanReminderContent (Messages.smContent msg)
+      keyboard =
+        Types.InlineKeyboardMarkup
+          [ [ Types.InlineKeyboardButton "✅ Complete" (Just ("reminder_complete:" <> reminderId)) Nothing,
+              Types.InlineKeyboardButton "⏰ 1h" (Just ("reminder_snooze_1h:" <> reminderId)) Nothing,
+              Types.InlineKeyboardButton "⏰ 1d" (Just ("reminder_snooze_1d:" <> reminderId)) Nothing
+            ]
+          ]
+  sendMessageWithKeyboard cfg (Messages.smChatId msg) content keyboard
+
+-- | Remove the [reminder_id:X] tag from reminder messages
+cleanReminderContent :: Text -> Text
+cleanReminderContent content =
+  -- Remove the "[reminder_id:123]" suffix we added for tracking
+  let lines' = Text.lines content
+      cleanedLines = filter (not <. Text.isPrefixOf "[reminder_id:") lines'
+   in Text.unlines cleanedLines
+
 -- | Answer a callback query (acknowledges button press)
 answerCallbackQuery :: Types.TelegramConfig -> Text -> Maybe Text -> IO ()
 answerCallbackQuery cfg callbackId maybeText = do
@@ -579,7 +611,7 @@ runTelegramBot tgConfig provider = do
   _ <- forkIO (Email.emailCheckLoop (sendMessageReturningId tgConfig) benChatId)
   putText "Email check loop started (checking every 6 hours)"
 
-  let sendFn = sendMessageReturningId tgConfig
+  let sendFn = makeMessageSendFn tgConfig
   _ <- forkIO (Messages.messageDispatchLoop sendFn)
   putText "Message dispatch loop started (1s polling)"
 
diff --git a/Omni/Agent/Telegram/Actions.hs b/Omni/Agent/Telegram/Actions.hs
index 8ed482da..e889dd01 100644
--- a/Omni/Agent/Telegram/Actions.hs
+++ b/Omni/Agent/Telegram/Actions.hs
@@ -39,6 +39,7 @@ import Alpha
 import qualified Data.Map.Strict as Map
 import qualified Data.Text as Text
 import qualified Omni.Agent.Subagent as Subagent
+import qualified Omni.Agent.Telegram.Reminders as Reminders
 import qualified Omni.Test as Test
 
 main :: IO ()
@@ -64,6 +65,9 @@ test =
         let keys = Map.keys actionRegistry
         ("subagent_approve" `elem` keys) Test.@=? True
         ("subagent_reject" `elem` keys) Test.@=? True
+        ("reminder_complete" `elem` keys) Test.@=? True
+        ("reminder_snooze_1h" `elem` keys) Test.@=? True
+        ("reminder_snooze_1d" `elem` keys) Test.@=? True
     ]
 
 -- | Input provided to an action execution
@@ -115,7 +119,10 @@ actionRegistry :: Map.Map Text Action
 actionRegistry =
   Map.fromList
     [ ("subagent_approve", approveSubagentAction),
-      ("subagent_reject", rejectSubagentAction)
+      ("subagent_reject", rejectSubagentAction),
+      ("reminder_complete", completeReminderAction),
+      ("reminder_snooze_1h", snooze1hAction),
+      ("reminder_snooze_1d", snooze1dAction)
     ]
 
 -- | Look up an action by its ID
@@ -178,3 +185,108 @@ rejectSubagentAction =
                   arOutcome = ActionFailed "already expired"
                 }
     }
+
+-- | Action to complete a reminder
+completeReminderAction :: Action
+completeReminderAction =
+  Action
+    { actionId = "reminder_complete",
+      actionExecute = \input -> do
+        let reminderId = aiPayload input
+            uid = tshow (aiUserId input)
+        case readMaybe (Text.unpack reminderId) of
+          Nothing ->
+            pure
+              ActionResult
+                { arUserMessage = "[Completed reminder: " <> reminderId <> "]",
+                  arAssistantMessage = "couldn't parse reminder ID.",
+                  arOutcome = ActionFailed "invalid reminder ID"
+                }
+          Just rid -> do
+            success <- Reminders.completeReminder uid rid
+            if success
+              then
+                pure
+                  ActionResult
+                    { arUserMessage = "[Completed reminder: " <> reminderId <> "]",
+                      arAssistantMessage = "✅ marked complete!",
+                      arOutcome = ActionSuccess
+                    }
+              else
+                pure
+                  ActionResult
+                    { arUserMessage = "[Completed reminder: " <> reminderId <> " (not found)]",
+                      arAssistantMessage = "couldn't find that reminder.",
+                      arOutcome = ActionFailed "reminder not found"
+                    }
+    }
+
+-- | Action to snooze a reminder for 1 hour
+snooze1hAction :: Action
+snooze1hAction =
+  Action
+    { actionId = "reminder_snooze_1h",
+      actionExecute = \input -> do
+        let reminderId = aiPayload input
+            uid = tshow (aiUserId input)
+        case readMaybe (Text.unpack reminderId) of
+          Nothing ->
+            pure
+              ActionResult
+                { arUserMessage = "[Snoozed 1h: " <> reminderId <> "]",
+                  arAssistantMessage = "couldn't parse reminder ID.",
+                  arOutcome = ActionFailed "invalid reminder ID"
+                }
+          Just rid -> do
+            success <- Reminders.snoozeReminder uid rid (60 * 60) -- 1 hour
+            if success
+              then
+                pure
+                  ActionResult
+                    { arUserMessage = "[Snoozed 1h: " <> reminderId <> "]",
+                      arAssistantMessage = "⏰ snoozed for 1 hour.",
+                      arOutcome = ActionSuccess
+                    }
+              else
+                pure
+                  ActionResult
+                    { arUserMessage = "[Snoozed 1h: " <> reminderId <> " (not found)]",
+                      arAssistantMessage = "couldn't find that reminder.",
+                      arOutcome = ActionFailed "reminder not found"
+                    }
+    }
+
+-- | Action to snooze a reminder for 1 day
+snooze1dAction :: Action
+snooze1dAction =
+  Action
+    { actionId = "reminder_snooze_1d",
+      actionExecute = \input -> do
+        let reminderId = aiPayload input
+            uid = tshow (aiUserId input)
+        case readMaybe (Text.unpack reminderId) of
+          Nothing ->
+            pure
+              ActionResult
+                { arUserMessage = "[Snoozed 1d: " <> reminderId <> "]",
+                  arAssistantMessage = "couldn't parse reminder ID.",
+                  arOutcome = ActionFailed "invalid reminder ID"
+                }
+          Just rid -> do
+            success <- Reminders.snoozeReminder uid rid (24 * 60 * 60) -- 1 day
+            if success
+              then
+                pure
+                  ActionResult
+                    { arUserMessage = "[Snoozed 1d: " <> reminderId <> "]",
+                      arAssistantMessage = "⏰ snoozed for 1 day.",
+                      arOutcome = ActionSuccess
+                    }
+              else
+                pure
+                  ActionResult
+                    { arUserMessage = "[Snoozed 1d: " <> reminderId <> " (not found)]",
+                      arAssistantMessage = "couldn't find that reminder.",
+                      arOutcome = ActionFailed "reminder not found"
+                    }
+    }
diff --git a/Omni/Agent/Telegram/Messages.hs b/Omni/Agent/Telegram/Messages.hs
index eab9668b..848cd7ac 100644
--- a/Omni/Agent/Telegram/Messages.hs
+++ b/Omni/Agent/Telegram/Messages.hs
@@ -396,7 +396,10 @@ cancelMessage msgId =
     changes <- SQL.changes conn
     pure (changes > 0)
 
-messageDispatchLoop :: (Int -> Maybe Int -> Text -> IO (Maybe Int)) -> IO ()
+-- | Run the message dispatch loop with a message-aware send function.
+-- The send function receives the full ScheduledMessage so it can handle
+-- different message types (e.g., reminders with inline keyboards).
+messageDispatchLoop :: (ScheduledMessage -> IO (Maybe Int)) -> IO ()
 messageDispatchLoop sendFn =
   forever <| do
     now <- getCurrentTime
@@ -407,11 +410,11 @@ messageDispatchLoop sendFn =
         forM_ due <| \m -> dispatchOne sendFn m
         when (length due < 10) <| threadDelay 1000000
 
-dispatchOne :: (Int -> Maybe Int -> Text -> IO (Maybe Int)) -> ScheduledMessage -> IO ()
+dispatchOne :: (ScheduledMessage -> IO (Maybe Int)) -> ScheduledMessage -> IO ()
 dispatchOne sendFn m = do
   now <- getCurrentTime
   markSending (smId m) now
-  result <- try (sendFn (smChatId m) (smThreadId m) (smContent m))
+  result <- try (sendFn m)
   case result of
     Left (e :: SomeException) -> do
       let err = "Exception sending Telegram message: " <> tshow e
diff --git a/Omni/Agent/Telegram/Reminders.hs b/Omni/Agent/Telegram/Reminders.hs
index 4139d087..dffdda95 100644
--- a/Omni/Agent/Telegram/Reminders.hs
+++ b/Omni/Agent/Telegram/Reminders.hs
@@ -24,6 +24,10 @@ module Omni.Agent.Telegram.Reminders
     deleteReminder,
     parseReminderTime,
 
+    -- * Reminder Actions
+    completeReminder,
+    snoozeReminder,
+
     -- * Testing
     main,
     test,
@@ -32,7 +36,7 @@ where
 
 import Alpha
 import qualified Data.Text as Text
-import Data.Time (LocalTime, TimeZone, UTCTime, getCurrentTime, localTimeToUTC, minutesToTimeZone, utcToLocalTime)
+import Data.Time (LocalTime, NominalDiffTime, TimeZone, UTCTime, addUTCTime, getCurrentTime, localTimeToUTC, minutesToTimeZone, utcToLocalTime)
 import qualified Data.Time.Calendar as Calendar
 import Data.Time.Format (defaultTimeLocale, formatTime, parseTimeM)
 import Data.Time.LocalTime (LocalTime (..), TimeOfDay (..), localDay, localTimeOfDay)
@@ -306,15 +310,39 @@ checkAndSendReminders = do
       Just chatId -> do
         let title = Todos.todoTitle td
             uid = Todos.todoUserId td
+            tid = Todos.todoId td
             dueStr = case Todos.todoDueDate td of
               Just d -> " (due: " <> tshow d <> ")"
               Nothing -> ""
+            -- Message now includes reminder ID for action buttons
             msg =
               "⏰ reminder: \""
                 <> title
                 <> "\""
                 <> dueStr
-                <> "\nreply when you finish and i'll mark it complete."
-        _ <- Messages.enqueueImmediate (Just uid) chatId Nothing msg (Just "reminder") Nothing
-        Todos.markReminderSent (Todos.todoId td)
-        putText <| "Queued reminder for todo " <> tshow (Todos.todoId td) <> " to chat " <> tshow chatId
+                <> "\n\n[reminder_id:" <> tshow tid <> "]"
+        -- Note: The actual inline keyboard is added in Telegram.hs when sending
+        -- We store the reminder ID in the message for the callback to use
+        _ <- Messages.enqueueImmediate (Just uid) chatId Nothing msg (Just "reminder") (Just (tshow tid))
+        Todos.markReminderSent tid
+        putText <| "Queued reminder for todo " <> tshow tid <> " to chat " <> tshow chatId
+
+-- | Complete a reminder (mark as done)
+completeReminder :: Text -> Int -> IO Bool
+completeReminder uid reminderId = Todos.completeTodo uid reminderId
+
+-- | Snooze a reminder by a given duration
+snoozeReminder :: Text -> Int -> NominalDiffTime -> IO Bool
+snoozeReminder uid reminderId duration = do
+  now <- getCurrentTime
+  let newDueAt = addUTCTime duration now
+      newDueDateStr = Text.pack (formatTime defaultTimeLocale "%Y-%m-%d %H:%M" (utcToLocalTime easternTimeZone newDueAt))
+  Memory.withMemoryDb <| \conn -> do
+    Todos.initTodosTable conn
+    -- Update due date and clear last_reminded_at so it will fire again
+    SQL.execute
+      conn
+      "UPDATE todos SET due_date = ?, last_reminded_at = NULL WHERE id = ? AND user_id = ?"
+      (newDueDateStr, reminderId, uid)
+    changes <- SQL.changes conn
+    pure (changes > 0)