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)