From 817bdb1f33e9825946a2da2aa1ff8f91b6166366 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Fri, 12 Dec 2025 23:30:04 -0500 Subject: telegram bot: refactor + multimedia + reply support Refactor Telegram.hs into submodules to reduce file size: - Types.hs: data types, JSON parsing - Media.hs: file downloads, image/voice analysis - Reminders.hs: reminder loop, user chat persistence Multimedia improvements: - Vision uses third-person to avoid LLM confusion - Better message framing for embedded descriptions - Size validation (10MB images, 20MB voice) - MIME type validation for voice messages New features: - Reply support: bot sees context when users reply - Web search: default 5->10, max 10->20 results - Guardrails: duplicate tool limit 3->10 for research - Timezone: todos parse/display in Eastern time (ET) --- Omni/Agent/Telegram/Reminders.hs | 107 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 Omni/Agent/Telegram/Reminders.hs (limited to 'Omni/Agent/Telegram/Reminders.hs') diff --git a/Omni/Agent/Telegram/Reminders.hs b/Omni/Agent/Telegram/Reminders.hs new file mode 100644 index 0000000..706f9da --- /dev/null +++ b/Omni/Agent/Telegram/Reminders.hs @@ -0,0 +1,107 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Telegram Reminders - Background reminder loop and user chat persistence. +-- +-- : out omni-agent-telegram-reminders +-- : dep sqlite-simple +module Omni.Agent.Telegram.Reminders + ( -- * User Chat Persistence + initUserChatsTable, + recordUserChat, + lookupChatId, + + -- * Reminder Loop + reminderLoop, + checkAndSendReminders, + + -- * Testing + main, + test, + ) +where + +import Alpha +import Data.Time (getCurrentTime) +import qualified Database.SQLite.Simple as SQL +import qualified Omni.Agent.Memory as Memory +import qualified Omni.Agent.Telegram.Types as Types +import qualified Omni.Agent.Tools.Todos as Todos +import qualified Omni.Test as Test + +main :: IO () +main = Test.run test + +test :: Test.Tree +test = + Test.group + "Omni.Agent.Telegram.Reminders" + [ Test.unit "initUserChatsTable is idempotent" <| do + Memory.withMemoryDb <| \conn -> do + initUserChatsTable conn + initUserChatsTable conn + pure () + ] + +initUserChatsTable :: SQL.Connection -> IO () +initUserChatsTable conn = + SQL.execute_ + conn + "CREATE TABLE IF NOT EXISTS user_chats (\ + \ user_id TEXT PRIMARY KEY,\ + \ chat_id INTEGER NOT NULL,\ + \ last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\ + \)" + +recordUserChat :: Text -> Int -> IO () +recordUserChat uid chatId = do + now <- getCurrentTime + Memory.withMemoryDb <| \conn -> do + initUserChatsTable conn + SQL.execute + conn + "INSERT INTO user_chats (user_id, chat_id, last_seen_at) \ + \VALUES (?, ?, ?) \ + \ON CONFLICT(user_id) DO UPDATE SET \ + \ chat_id = excluded.chat_id, \ + \ last_seen_at = excluded.last_seen_at" + (uid, chatId, now) + +lookupChatId :: Text -> IO (Maybe Int) +lookupChatId uid = + Memory.withMemoryDb <| \conn -> do + initUserChatsTable conn + rows <- + SQL.query + conn + "SELECT chat_id FROM user_chats WHERE user_id = ?" + (SQL.Only uid) + pure (listToMaybe (map SQL.fromOnly rows)) + +reminderLoop :: Types.TelegramConfig -> (Types.TelegramConfig -> Int -> Text -> IO ()) -> IO () +reminderLoop tgConfig sendMsg = + forever <| do + threadDelay (5 * 60 * 1000000) + checkAndSendReminders tgConfig sendMsg + +checkAndSendReminders :: Types.TelegramConfig -> (Types.TelegramConfig -> Int -> Text -> IO ()) -> IO () +checkAndSendReminders tgConfig sendMsg = do + todos <- Todos.listTodosDueForReminder + forM_ todos <| \td -> do + mChatId <- lookupChatId (Todos.todoUserId td) + case mChatId of + Nothing -> pure () + Just chatId -> do + let title = Todos.todoTitle td + dueStr = case Todos.todoDueDate td of + Just d -> " (due: " <> tshow d <> ")" + Nothing -> "" + msg = + "⏰ reminder: \"" + <> title + <> "\"" + <> dueStr + <> "\nreply when you finish and i'll mark it complete." + sendMsg tgConfig chatId msg + Todos.markReminderSent (Todos.todoId td) + putText <| "Sent reminder for todo " <> tshow (Todos.todoId td) <> " to chat " <> tshow chatId -- cgit v1.2.3 From 4d21f170cd1d1df239d7ad00fbf79427769a140f Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Sat, 13 Dec 2025 13:09:32 -0500 Subject: telegram: unified message queue with async/scheduled sends - Add Messages.hs with scheduled_messages table and dispatcher loop - All outbound messages now go through the queue (1s polling) - Disable streaming responses, use runAgentWithProvider instead - Add send_message tool for delayed messages (up to 30 days) - Add list_pending_messages and cancel_message tools - Reminders now queue messages instead of sending directly - Exponential backoff retry (max 5 attempts) for failed sends --- Omni/Agent/Telegram/Reminders.hs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) (limited to 'Omni/Agent/Telegram/Reminders.hs') diff --git a/Omni/Agent/Telegram/Reminders.hs b/Omni/Agent/Telegram/Reminders.hs index 706f9da..cc631a0 100644 --- a/Omni/Agent/Telegram/Reminders.hs +++ b/Omni/Agent/Telegram/Reminders.hs @@ -25,7 +25,7 @@ import Alpha import Data.Time (getCurrentTime) import qualified Database.SQLite.Simple as SQL import qualified Omni.Agent.Memory as Memory -import qualified Omni.Agent.Telegram.Types as Types +import qualified Omni.Agent.Telegram.Messages as Messages import qualified Omni.Agent.Tools.Todos as Todos import qualified Omni.Test as Test @@ -78,14 +78,14 @@ lookupChatId uid = (SQL.Only uid) pure (listToMaybe (map SQL.fromOnly rows)) -reminderLoop :: Types.TelegramConfig -> (Types.TelegramConfig -> Int -> Text -> IO ()) -> IO () -reminderLoop tgConfig sendMsg = +reminderLoop :: IO () +reminderLoop = forever <| do threadDelay (5 * 60 * 1000000) - checkAndSendReminders tgConfig sendMsg + checkAndSendReminders -checkAndSendReminders :: Types.TelegramConfig -> (Types.TelegramConfig -> Int -> Text -> IO ()) -> IO () -checkAndSendReminders tgConfig sendMsg = do +checkAndSendReminders :: IO () +checkAndSendReminders = do todos <- Todos.listTodosDueForReminder forM_ todos <| \td -> do mChatId <- lookupChatId (Todos.todoUserId td) @@ -93,6 +93,7 @@ checkAndSendReminders tgConfig sendMsg = do Nothing -> pure () Just chatId -> do let title = Todos.todoTitle td + uid = Todos.todoUserId td dueStr = case Todos.todoDueDate td of Just d -> " (due: " <> tshow d <> ")" Nothing -> "" @@ -102,6 +103,6 @@ checkAndSendReminders tgConfig sendMsg = do <> "\"" <> dueStr <> "\nreply when you finish and i'll mark it complete." - sendMsg tgConfig chatId msg + _ <- Messages.enqueueImmediate (Just uid) chatId msg (Just "reminder") Nothing Todos.markReminderSent (Todos.todoId td) - putText <| "Sent reminder for todo " <> tshow (Todos.todoId td) <> " to chat " <> tshow chatId + putText <| "Queued reminder for todo " <> tshow (Todos.todoId td) <> " to chat " <> tshow chatId -- cgit v1.2.3 From c35ba7d248642386544a776f86815e01630eb50d Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Sat, 13 Dec 2025 15:03:11 -0500 Subject: feat: add Telegram topic (message_thread_id) support - Parse message_thread_id from incoming messages - Include thread_id in sendMessage API calls - Pass thread_id through message queue system - Replies now go to the correct topic in supergroups --- Omni/Agent/Telegram/Reminders.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Omni/Agent/Telegram/Reminders.hs') diff --git a/Omni/Agent/Telegram/Reminders.hs b/Omni/Agent/Telegram/Reminders.hs index cc631a0..88aab0a 100644 --- a/Omni/Agent/Telegram/Reminders.hs +++ b/Omni/Agent/Telegram/Reminders.hs @@ -103,6 +103,6 @@ checkAndSendReminders = do <> "\"" <> dueStr <> "\nreply when you finish and i'll mark it complete." - _ <- Messages.enqueueImmediate (Just uid) chatId msg (Just "reminder") Nothing + _ <- 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 -- cgit v1.2.3