diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-11 22:51:44 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-11 22:51:44 -0500 |
| commit | 37a28ead25b5e8e38076905feefa3fa9c8c86604 (patch) | |
| tree | f1964255006622059625f6d0a0d25422a8892195 /Omni | |
| parent | ff89735dab5d923b13dc6fdca8af7cd448e6234e (diff) | |
Add Telegram bot agent (t-251)
- Omni/Agent/Telegram.hs: Telegram API client with getUpdates/sendMessage
- Omni/Bot.hs: Standalone CLI for running the bot
- User identification via Memory.getOrCreateUserByTelegramId
- Memory-enhanced agent with remember/recall tools
- Run with: bot --token=XXX or TELEGRAM_BOT_TOKEN env var
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Agent/Telegram.hs | 408 | ||||
| -rw-r--r-- | Omni/Bot.hs | 66 |
2 files changed, 474 insertions, 0 deletions
diff --git a/Omni/Agent/Telegram.hs b/Omni/Agent/Telegram.hs new file mode 100644 index 0000000..dd3df51 --- /dev/null +++ b/Omni/Agent/Telegram.hs @@ -0,0 +1,408 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Telegram Bot Agent - Family assistant via Telegram. +-- +-- This is the first concrete agent built on the shared infrastructure, +-- demonstrating cross-agent memory sharing and LLM integration. +-- +-- Usage: +-- jr telegram # Uses TELEGRAM_BOT_TOKEN env var +-- jr telegram --token=XXX # Explicit token +-- +-- : out omni-agent-telegram +-- : dep aeson +-- : dep http-conduit +-- : dep stm +module Omni.Agent.Telegram + ( -- * Configuration + TelegramConfig (..), + defaultTelegramConfig, + + -- * Types + TelegramMessage (..), + TelegramUpdate (..), + + -- * Telegram API + getUpdates, + sendMessage, + + -- * Bot Loop + runTelegramBot, + handleMessage, + startBot, + + -- * System Prompt + telegramSystemPrompt, + + -- * Testing + main, + test, + ) +where + +import Alpha +import Control.Concurrent.STM (newTVarIO, readTVarIO, writeTVar) +import Data.Aeson ((.!=), (.:), (.:?), (.=)) +import qualified Data.Aeson as Aeson +import qualified Data.Aeson.KeyMap as KeyMap +import qualified Data.Text as Text +import qualified Network.HTTP.Simple as HTTP +import qualified Omni.Agent.Engine as Engine +import qualified Omni.Agent.Memory as Memory +import qualified Omni.Agent.Provider as Provider +import qualified Omni.Test as Test +import System.Environment (lookupEnv) + +main :: IO () +main = Test.run test + +test :: Test.Tree +test = + Test.group + "Omni.Agent.Telegram" + [ Test.unit "TelegramConfig JSON roundtrip" <| do + let cfg = + TelegramConfig + { tgBotToken = "test-token", + tgPollingTimeout = 30, + tgApiBaseUrl = "https://api.telegram.org" + } + case Aeson.decode (Aeson.encode cfg) of + Nothing -> Test.assertFailure "Failed to decode TelegramConfig" + Just decoded -> tgBotToken decoded Test.@=? "test-token", + Test.unit "TelegramMessage JSON roundtrip" <| do + let msg = + TelegramMessage + { tmUpdateId = 123, + tmChatId = 456, + tmUserId = 789, + tmUserFirstName = "Test", + tmUserLastName = Just "User", + tmText = "Hello bot" + } + case Aeson.decode (Aeson.encode msg) of + Nothing -> Test.assertFailure "Failed to decode TelegramMessage" + Just decoded -> do + tmUpdateId decoded Test.@=? 123 + tmText decoded Test.@=? "Hello bot", + Test.unit "telegramSystemPrompt is non-empty" <| do + Text.null telegramSystemPrompt Test.@=? False, + Test.unit "parseUpdate extracts message correctly" <| do + let json = + Aeson.object + [ "update_id" .= (123 :: Int), + "message" + .= Aeson.object + [ "message_id" .= (1 :: Int), + "chat" .= Aeson.object ["id" .= (456 :: Int)], + "from" + .= Aeson.object + [ "id" .= (789 :: Int), + "first_name" .= ("Test" :: Text) + ], + "text" .= ("Hello" :: Text) + ] + ] + case parseUpdate json of + Nothing -> Test.assertFailure "Failed to parse update" + Just msg -> do + tmUpdateId msg Test.@=? 123 + tmChatId msg Test.@=? 456 + tmUserId msg Test.@=? 789 + tmText msg Test.@=? "Hello" + ] + +-- | Telegram bot configuration. +data TelegramConfig = TelegramConfig + { tgBotToken :: Text, + tgPollingTimeout :: Int, + tgApiBaseUrl :: Text + } + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON TelegramConfig where + toJSON c = + Aeson.object + [ "bot_token" .= tgBotToken c, + "polling_timeout" .= tgPollingTimeout c, + "api_base_url" .= tgApiBaseUrl c + ] + +instance Aeson.FromJSON TelegramConfig where + parseJSON = + Aeson.withObject "TelegramConfig" <| \v -> + (TelegramConfig </ (v .: "bot_token")) + <*> (v .:? "polling_timeout" .!= 30) + <*> (v .:? "api_base_url" .!= "https://api.telegram.org") + +-- | Default Telegram configuration (requires token from env). +defaultTelegramConfig :: Text -> TelegramConfig +defaultTelegramConfig token = + TelegramConfig + { tgBotToken = token, + tgPollingTimeout = 30, + tgApiBaseUrl = "https://api.telegram.org" + } + +-- | A parsed Telegram message from a user. +data TelegramMessage = TelegramMessage + { tmUpdateId :: Int, + tmChatId :: Int, + tmUserId :: Int, + tmUserFirstName :: Text, + tmUserLastName :: Maybe Text, + tmText :: Text + } + deriving (Show, Eq, Generic) + +instance Aeson.ToJSON TelegramMessage where + toJSON m = + Aeson.object + [ "update_id" .= tmUpdateId m, + "chat_id" .= tmChatId m, + "user_id" .= tmUserId m, + "user_first_name" .= tmUserFirstName m, + "user_last_name" .= tmUserLastName m, + "text" .= tmText m + ] + +instance Aeson.FromJSON TelegramMessage where + parseJSON = + Aeson.withObject "TelegramMessage" <| \v -> + (TelegramMessage </ (v .: "update_id")) + <*> (v .: "chat_id") + <*> (v .: "user_id") + <*> (v .: "user_first_name") + <*> (v .:? "user_last_name") + <*> (v .: "text") + +-- | Raw Telegram update for parsing. +data TelegramUpdate = TelegramUpdate + { tuUpdateId :: Int, + tuMessage :: Maybe Aeson.Value + } + deriving (Show, Eq, Generic) + +instance Aeson.FromJSON TelegramUpdate where + parseJSON = + Aeson.withObject "TelegramUpdate" <| \v -> + (TelegramUpdate </ (v .: "update_id")) + <*> (v .:? "message") + +-- | Parse a Telegram update into a TelegramMessage. +parseUpdate :: Aeson.Value -> Maybe TelegramMessage +parseUpdate val = do + Aeson.Object obj <- pure val + updateId <- case KeyMap.lookup "update_id" obj of + Just (Aeson.Number n) -> Just (round n) + _ -> Nothing + Aeson.Object msgObj <- KeyMap.lookup "message" obj + Aeson.Object chatObj <- KeyMap.lookup "chat" msgObj + chatId <- case KeyMap.lookup "id" chatObj of + Just (Aeson.Number n) -> Just (round n) + _ -> Nothing + Aeson.Object fromObj <- KeyMap.lookup "from" msgObj + userId <- case KeyMap.lookup "id" fromObj of + Just (Aeson.Number n) -> Just (round n) + _ -> Nothing + firstName <- case KeyMap.lookup "first_name" fromObj of + Just (Aeson.String s) -> Just s + _ -> Nothing + let lastName = case KeyMap.lookup "last_name" fromObj of + Just (Aeson.String s) -> Just s + _ -> Nothing + text <- case KeyMap.lookup "text" msgObj of + Just (Aeson.String s) -> Just s + _ -> Nothing + pure + TelegramMessage + { tmUpdateId = updateId, + tmChatId = chatId, + tmUserId = userId, + tmUserFirstName = firstName, + tmUserLastName = lastName, + tmText = text + } + +-- | Poll Telegram for new updates. +getUpdates :: TelegramConfig -> Int -> IO [TelegramMessage] +getUpdates cfg offset = do + let url = + Text.unpack (tgApiBaseUrl cfg) + <> "/bot" + <> Text.unpack (tgBotToken cfg) + <> "/getUpdates" + req0 <- HTTP.parseRequest url + let body = + Aeson.object + [ "offset" .= offset, + "timeout" .= tgPollingTimeout cfg, + "allowed_updates" .= (["message"] :: [Text]) + ] + req = + HTTP.setRequestMethod "POST" + <| HTTP.setRequestHeader "Content-Type" ["application/json"] + <| HTTP.setRequestBodyLBS (Aeson.encode body) + <| req0 + result <- try (HTTP.httpLBS req) + case result of + Left (e :: SomeException) -> do + putText <| "Telegram API error: " <> tshow e + pure [] + Right response -> do + let status = HTTP.getResponseStatusCode response + if status >= 200 && status < 300 + then case Aeson.decode (HTTP.getResponseBody response) of + Just (Aeson.Object obj) -> case KeyMap.lookup "result" obj of + Just (Aeson.Array arr) -> + pure (mapMaybe parseUpdate (toList arr)) + _ -> pure [] + _ -> pure [] + else do + putText <| "Telegram HTTP error: " <> tshow status + pure [] + +-- | Send a message to a Telegram chat. +sendMessage :: TelegramConfig -> Int -> Text -> IO () +sendMessage cfg chatId text = do + let url = + Text.unpack (tgApiBaseUrl cfg) + <> "/bot" + <> Text.unpack (tgBotToken cfg) + <> "/sendMessage" + req0 <- HTTP.parseRequest url + let body = + Aeson.object + [ "chat_id" .= chatId, + "text" .= text, + "parse_mode" .= ("Markdown" :: Text) + ] + req = + HTTP.setRequestMethod "POST" + <| HTTP.setRequestHeader "Content-Type" ["application/json"] + <| HTTP.setRequestBodyLBS (Aeson.encode body) + <| req0 + result <- try (HTTP.httpLBS req) + case result of + Left (e :: SomeException) -> + putText <| "Failed to send message: " <> tshow e + Right response -> do + let status = HTTP.getResponseStatusCode response + unless (status >= 200 && status < 300) + <| putText + <| "Send message failed: " + <> tshow status + +-- | System prompt for the Telegram bot agent. +telegramSystemPrompt :: Text +telegramSystemPrompt = + Text.unlines + [ "You are a helpful family assistant on Telegram. You help with questions,", + "remember important information about family members, and provide friendly assistance.", + "", + "When you learn something important about the user (preferences, facts about them,", + "their interests, family details), use the 'remember' tool to store it for future reference.", + "", + "Be concise in responses - Telegram is a chat interface, not a document.", + "Keep responses under 200 words unless the user asks for detail.", + "Be friendly and helpful. This is a family bot, keep content appropriate.", + "", + "If the user asks something you don't know, be honest about it.", + "You can use the 'recall' tool to search your memory for relevant information." + ] + +-- | Run the Telegram bot main loop. +runTelegramBot :: TelegramConfig -> Provider.Provider -> IO () +runTelegramBot tgConfig provider = do + putText "Starting Telegram bot..." + offsetVar <- newTVarIO 0 + + let engineCfg = Engine.defaultEngineConfig + + forever <| do + offset <- readTVarIO offsetVar + messages <- getUpdates tgConfig offset + forM_ messages <| \msg -> do + atomically (writeTVar offsetVar (tmUpdateId msg + 1)) + handleMessage tgConfig provider engineCfg msg + when (null messages) <| threadDelay 1000000 + +-- | Handle a single incoming message. +handleMessage :: + TelegramConfig -> + Provider.Provider -> + Engine.EngineConfig -> + TelegramMessage -> + IO () +handleMessage tgConfig provider engineCfg msg = do + let userName = + tmUserFirstName msg + <> maybe "" (" " <>) (tmUserLastName msg) + + user <- Memory.getOrCreateUserByTelegramId (tmUserId msg) userName + + memories <- Memory.recallMemories (Memory.userId user) (tmText msg) 5 + let memoryContext = Memory.formatMemoriesForPrompt memories + + let systemPrompt = + telegramSystemPrompt + <> "\n\n## What you know about this user\n" + <> memoryContext + + let tools = + [ Memory.rememberTool (Memory.userId user), + Memory.recallTool (Memory.userId user) + ] + + let agentCfg = + Engine.defaultAgentConfig + { Engine.agentSystemPrompt = systemPrompt, + Engine.agentTools = tools, + Engine.agentMaxIterations = 5, + Engine.agentGuardrails = + Engine.defaultGuardrails + { Engine.guardrailMaxCostCents = 10.0 + } + } + + result <- Engine.runAgentWithProvider engineCfg provider agentCfg (tmText msg) + + case result of + Left err -> do + putText <| "Agent error: " <> err + sendMessage tgConfig (tmChatId msg) "Sorry, I encountered an error. Please try again." + Right agentResult -> do + let response = Engine.resultFinalMessage agentResult + sendMessage tgConfig (tmChatId msg) response + putText + <| "Responded to " + <> userName + <> " (cost: " + <> tshow (Engine.resultTotalCost agentResult) + <> " cents)" + +-- | Start the Telegram bot from environment or provided token. +startBot :: Maybe Text -> IO () +startBot maybeToken = do + token <- case maybeToken of + Just t -> pure t + Nothing -> do + envToken <- lookupEnv "TELEGRAM_BOT_TOKEN" + case envToken of + Just t -> pure (Text.pack t) + Nothing -> do + putText "Error: TELEGRAM_BOT_TOKEN not set and no --token provided" + exitFailure + + apiKey <- lookupEnv "OPENROUTER_API_KEY" + case apiKey of + Nothing -> do + putText "Error: OPENROUTER_API_KEY not set" + exitFailure + Just key -> do + let tgConfig = defaultTelegramConfig token + provider = Provider.defaultOpenRouter (Text.pack key) "anthropic/claude-sonnet-4" + runTelegramBot tgConfig provider diff --git a/Omni/Bot.hs b/Omni/Bot.hs new file mode 100644 index 0000000..77a0408 --- /dev/null +++ b/Omni/Bot.hs @@ -0,0 +1,66 @@ +#!/usr/bin/env run.sh +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE NoImplicitPrelude #-} + +-- | Omni Bot - Family assistant via Telegram. +-- +-- Usage: +-- bot # Uses TELEGRAM_BOT_TOKEN env var +-- bot --token=XXX # Explicit token +-- bot --model=MODEL # Override LLM model +-- +-- : out bot +-- : dep aeson +-- : dep http-conduit +-- : dep stm +module Omni.Bot where + +import Alpha +import qualified Data.Text as Text +import qualified Omni.Agent.Telegram as Telegram +import qualified Omni.Cli as Cli +import qualified Omni.Test as Test +import qualified System.Console.Docopt as Docopt + +main :: IO () +main = Cli.main plan + +plan :: Cli.Plan () +plan = + Cli.Plan + { Cli.help = help, + Cli.move = move, + Cli.test = test, + Cli.tidy = \_ -> pure () + } + +help :: Cli.Docopt +help = + [Cli.docopt| +bot - Omni family assistant via Telegram + +Usage: + bot [--token=TOKEN] [--model=MODEL] + bot test + bot (-h | --help) + +Options: + -h --help Show this help + --token=TOKEN Telegram bot token (or use TELEGRAM_BOT_TOKEN env) + --model=MODEL LLM model to use [default: anthropic/claude-sonnet-4] +|] + +move :: Cli.Arguments -> IO () +move args = do + let maybeToken = fmap Text.pack (Cli.getArg args (Cli.longOption "token")) + Telegram.startBot maybeToken + +test :: Test.Tree +test = + Test.group + "Omni.Bot" + [ Test.unit "help is non-empty" <| do + let usage = str (Docopt.usage help) :: String + null usage Test.@=? False + ] |
