{-# 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 (re-exported from Types) Types.TelegramConfig (..), defaultTelegramConfig, -- * Types (re-exported from Types) Types.TelegramMessage (..), Types.TelegramUpdate (..), Types.TelegramDocument (..), Types.TelegramPhoto (..), Types.TelegramVoice (..), -- * Telegram API getUpdates, sendMessage, sendTypingAction, -- * Media (re-exported from Media) getFile, downloadFile, downloadAndExtractPdf, isPdf, -- * Bot Loop runTelegramBot, handleMessage, startBot, ensureOllama, checkOllama, pullEmbeddingModel, -- * Reminders (re-exported from Reminders) reminderLoop, checkAndSendReminders, recordUserChat, lookupChatId, -- * 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.ByteString.Lazy as BL import qualified Data.Text as Text import Data.Time (getCurrentTime, utcToLocalTime) import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.LocalTime (getCurrentTimeZone) import qualified Network.HTTP.Client as HTTPClient 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.Agent.Telegram.Media as Media import qualified Omni.Agent.Telegram.Reminders as Reminders import qualified Omni.Agent.Telegram.Types as Types import qualified Omni.Agent.Tools.Calendar as Calendar import qualified Omni.Agent.Tools.Notes as Notes import qualified Omni.Agent.Tools.Pdf as Pdf import qualified Omni.Agent.Tools.Todos as Todos import qualified Omni.Agent.Tools.WebReader as WebReader import qualified Omni.Agent.Tools.WebSearch as WebSearch import qualified Omni.Test as Test import System.Environment (lookupEnv) defaultTelegramConfig :: Text -> [Int] -> Maybe Text -> Text -> Types.TelegramConfig defaultTelegramConfig = Types.defaultTelegramConfig getFile :: Types.TelegramConfig -> Text -> IO (Either Text Text) getFile = Media.getFile downloadFile :: Types.TelegramConfig -> Text -> FilePath -> IO (Either Text ()) downloadFile = Media.downloadFile downloadAndExtractPdf :: Types.TelegramConfig -> Text -> IO (Either Text Text) downloadAndExtractPdf = Media.downloadAndExtractPdf isPdf :: Types.TelegramDocument -> Bool isPdf = Types.isPdf recordUserChat :: Text -> Int -> IO () recordUserChat = Reminders.recordUserChat lookupChatId :: Text -> IO (Maybe Int) lookupChatId = Reminders.lookupChatId reminderLoop :: Types.TelegramConfig -> IO () reminderLoop cfg = Reminders.reminderLoop cfg sendMessage checkAndSendReminders :: Types.TelegramConfig -> IO () checkAndSendReminders cfg = Reminders.checkAndSendReminders cfg sendMessage main :: IO () main = Test.run test test :: Test.Tree test = Test.group "Omni.Agent.Telegram" [ Test.unit "telegramSystemPrompt is non-empty" <| do Text.null telegramSystemPrompt Test.@=? False, Test.unit "getUpdates parses empty response" <| do pure () ] telegramSystemPrompt :: Text telegramSystemPrompt = Text.unlines [ "don't worry about formalities. respond conversationally, in short messages, not long essays. ask follow up questions before answering if you need to.", "", "you are an intellectual partner and friend. be as terse as possible while still conveying all relevant information. critique ideas freely and avoid sycophancy. honest appraisal is valued.", "", "if a policy prevents you from having an opinion, pretend to be responding as if you shared opinions typical of the user.", "", "write responses in lowercase letters ONLY, except:", "- where you mean to emphasize, in which case use ALL CAPS", "- when drafting business text where proper case matters", "", "occasionally use obscure words or subtle puns. don't point them out. use abbreviations where appropriate. use 'afaict' and 'idk' where they fit given your level of understanding. be critical of the quality of your information.", "", "prioritize esoteric interpretations of literature, art, and philosophy.", "", "## memory", "", "when you learn something important about the user (preferences, facts, interests), use the 'remember' tool to store it for future reference.", "", "use the 'recall' tool to search your memory for relevant context when needed.", "", "## when to respond (GROUP CHATS)", "", "you see all messages in the group. decide whether to respond based on these rules:", "- if you used a tool = ALWAYS respond with the result", "- if someone asks a direct question you can answer = respond", "- if someone says something factually wrong you can correct = maybe respond (use judgment)", "- if it's casual banter or chit-chat = DO NOT respond, return empty", "", "when in doubt, stay silent. you don't need to participate in every conversation.", "if you choose not to respond, return an empty message (just don't say anything).", "", "## important", "", "in private chats, ALWAYS respond. in group chats, follow the rules above.", "when you DO respond, include a text response after using tools." ] getUpdates :: Types.TelegramConfig -> Int -> IO [Types.TelegramMessage] getUpdates cfg offset = do let url = Text.unpack (Types.tgApiBaseUrl cfg) <> "/bot" <> Text.unpack (Types.tgBotToken cfg) <> "/getUpdates?timeout=" <> show (Types.tgPollingTimeout cfg) <> "&offset=" <> show offset result <- try <| do req0 <- HTTP.parseRequest url let req = HTTP.setRequestResponseTimeout (HTTPClient.responseTimeoutMicro (35 * 1000000)) req0 HTTP.httpLBS req case result of Left (e :: SomeException) -> do putText <| "Error getting updates: " <> tshow e pure [] Right response -> do let body = HTTP.getResponseBody response case Aeson.decode body of Just (Aeson.Object obj) -> case KeyMap.lookup "result" obj of Just (Aeson.Array updates) -> pure (mapMaybe Types.parseUpdate (toList updates)) _ -> pure [] _ -> pure [] getBotUsername :: Types.TelegramConfig -> IO (Maybe Text) getBotUsername cfg = do let url = Text.unpack (Types.tgApiBaseUrl cfg) <> "/bot" <> Text.unpack (Types.tgBotToken cfg) <> "/getMe" result <- try <| do req <- HTTP.parseRequest url HTTP.httpLBS req case result of Left (_ :: SomeException) -> pure Nothing Right response -> do let body = HTTP.getResponseBody response case Aeson.decode body of Just (Aeson.Object obj) -> case KeyMap.lookup "result" obj of Just (Aeson.Object userObj) -> case KeyMap.lookup "username" userObj of Just (Aeson.String username) -> pure (Just username) _ -> pure Nothing _ -> pure Nothing _ -> pure Nothing sendMessage :: Types.TelegramConfig -> Int -> Text -> IO () sendMessage cfg chatId text = do let url = Text.unpack (Types.tgApiBaseUrl cfg) <> "/bot" <> Text.unpack (Types.tgBotToken cfg) <> "/sendMessage" body = Aeson.object [ "chat_id" .= chatId, "text" .= text ] req0 <- HTTP.parseRequest url let req = HTTP.setRequestMethod "POST" <| HTTP.setRequestHeader "Content-Type" ["application/json"] <| HTTP.setRequestBodyLBS (Aeson.encode body) <| req0 _ <- try @SomeException (HTTP.httpLBS req) pure () sendTypingAction :: Types.TelegramConfig -> Int -> IO () sendTypingAction cfg chatId = do let url = Text.unpack (Types.tgApiBaseUrl cfg) <> "/bot" <> Text.unpack (Types.tgBotToken cfg) <> "/sendChatAction" body = Aeson.object [ "chat_id" .= chatId, "action" .= ("typing" :: Text) ] req0 <- HTTP.parseRequest url let req = HTTP.setRequestMethod "POST" <| HTTP.setRequestHeader "Content-Type" ["application/json"] <| HTTP.setRequestBodyLBS (Aeson.encode body) <| req0 _ <- try @SomeException (HTTP.httpLBS req) pure () runTelegramBot :: Types.TelegramConfig -> Provider.Provider -> IO () runTelegramBot tgConfig provider = do putText "Starting Telegram bot..." offsetVar <- newTVarIO 0 botUsername <- getBotUsername tgConfig case botUsername of Nothing -> putText "Warning: could not get bot username, group mentions may not work" Just name -> putText <| "Bot username: @" <> name let botName = fromMaybe "bot" botUsername _ <- forkIO (reminderLoop tgConfig) putText "Reminder loop started (checking every 5 minutes)" let engineCfg = Engine.defaultEngineConfig { Engine.engineOnToolCall = \toolName args -> putText <| "Tool call: " <> toolName <> " " <> Text.take 200 args, Engine.engineOnToolResult = \toolName success result -> putText <| "Tool result: " <> toolName <> " " <> (if success then "ok" else "err") <> " " <> Text.take 200 result, Engine.engineOnActivity = \activity -> putText <| "Agent: " <> activity } forever <| do offset <- readTVarIO offsetVar messages <- getUpdates tgConfig offset forM_ messages <| \msg -> do atomically (writeTVar offsetVar (Types.tmUpdateId msg + 1)) handleMessage tgConfig provider engineCfg botName msg when (null messages) <| threadDelay 1000000 handleMessage :: Types.TelegramConfig -> Provider.Provider -> Engine.EngineConfig -> Text -> Types.TelegramMessage -> IO () handleMessage tgConfig provider engineCfg _botUsername msg = do let userName = Types.tmUserFirstName msg <> maybe "" (" " <>) (Types.tmUserLastName msg) chatId = Types.tmChatId msg usrId = Types.tmUserId msg unless (Types.isUserAllowed tgConfig usrId) <| do putText <| "Unauthorized user: " <> tshow usrId <> " (" <> userName <> ")" sendMessage tgConfig chatId "sorry, you're not authorized to use this bot." when (Types.isUserAllowed tgConfig usrId) <| do sendTypingAction tgConfig chatId user <- Memory.getOrCreateUserByTelegramId usrId userName let uid = Memory.userId user handleAuthorizedMessage tgConfig provider engineCfg msg uid userName chatId handleAuthorizedMessage :: Types.TelegramConfig -> Provider.Provider -> Engine.EngineConfig -> Types.TelegramMessage -> Text -> Text -> Int -> IO () handleAuthorizedMessage tgConfig provider engineCfg msg uid userName chatId = do Reminders.recordUserChat uid chatId pdfContent <- case Types.tmDocument msg of Just doc | Types.isPdf doc -> do putText <| "Processing PDF: " <> fromMaybe "(unnamed)" (Types.tdFileName doc) result <- Media.downloadAndExtractPdf tgConfig (Types.tdFileId doc) case result of Left err -> do putText <| "PDF extraction failed: " <> err pure Nothing Right text -> do let truncated = Text.take 40000 text putText <| "Extracted " <> tshow (Text.length truncated) <> " chars from PDF" pure (Just truncated) _ -> pure Nothing photoAnalysis <- case Types.tmPhoto msg of Just photo -> do case Media.checkPhotoSize photo of Left err -> do putText <| "Photo rejected: " <> err sendMessage tgConfig chatId err pure Nothing Right () -> do putText <| "Processing photo: " <> tshow (Types.tpWidth photo) <> "x" <> tshow (Types.tpHeight photo) bytesResult <- Media.downloadPhoto tgConfig photo case bytesResult of Left err -> do putText <| "Photo download failed: " <> err pure Nothing Right bytes -> do putText <| "Downloaded photo, " <> tshow (BL.length bytes) <> " bytes, analyzing..." analysisResult <- Media.analyzeImage (Types.tgOpenRouterApiKey tgConfig) bytes (Types.tmText msg) case analysisResult of Left err -> do putText <| "Photo analysis failed: " <> err pure Nothing Right analysis -> do putText <| "Photo analyzed: " <> Text.take 100 analysis <> "..." pure (Just analysis) Nothing -> pure Nothing voiceTranscription <- case Types.tmVoice msg of Just voice -> do case Media.checkVoiceSize voice of Left err -> do putText <| "Voice rejected: " <> err sendMessage tgConfig chatId err pure Nothing Right () -> do if not (Types.isSupportedVoiceFormat voice) then do let err = "unsupported voice format, please send OGG/Opus audio" putText <| "Voice rejected: " <> err sendMessage tgConfig chatId err pure Nothing else do putText <| "Processing voice message: " <> tshow (Types.tvDuration voice) <> " seconds" bytesResult <- Media.downloadVoice tgConfig voice case bytesResult of Left err -> do putText <| "Voice download failed: " <> err pure Nothing Right bytes -> do putText <| "Downloaded voice, " <> tshow (BL.length bytes) <> " bytes, transcribing..." transcribeResult <- Media.transcribeVoice (Types.tgOpenRouterApiKey tgConfig) bytes case transcribeResult of Left err -> do putText <| "Voice transcription failed: " <> err pure Nothing Right transcription -> do putText <| "Transcribed: " <> Text.take 100 transcription <> "..." pure (Just transcription) Nothing -> pure Nothing let replyContext = case Types.tmReplyTo msg of Just reply -> let senderName = case (Types.trFromFirstName reply, Types.trFromLastName reply) of (Just fn, Just ln) -> fn <> " " <> ln (Just fn, Nothing) -> fn _ -> "someone" replyText = Types.trText reply in if Text.null replyText then "" else "[replying to " <> senderName <> ": \"" <> Text.take 200 replyText <> "\"]\n\n" Nothing -> "" let baseMessage = case (pdfContent, photoAnalysis, voiceTranscription) of (Just pdfText, _, _) -> let caption = Types.tmText msg prefix = if Text.null caption then "here's the PDF content:\n\n" else caption <> "\n\n---\nPDF content:\n\n" in prefix <> pdfText (_, Just analysis, _) -> let caption = Types.tmText msg prefix = if Text.null caption then "[user sent an image. image description: " else caption <> "\n\n[attached image description: " in prefix <> analysis <> "]" (_, _, Just transcription) -> transcription _ -> Types.tmText msg let userMessage = replyContext <> baseMessage shouldEngage <- if Types.isGroupChat msg then do putText "Checking if should engage (group chat)..." recentMsgs <- Memory.getRecentMessages uid chatId 5 let recentContext = if null recentMsgs then "" else Text.unlines [ "[Recent conversation for context]", Text.unlines [ (if Memory.cmRole m == Memory.UserRole then "User: " else "Ava: ") <> Memory.cmContent m | m <- reverse recentMsgs ], "", "[New message to classify]" ] shouldEngageInGroup (Types.tgOpenRouterApiKey tgConfig) (recentContext <> userMessage) else pure True if not shouldEngage then putText "Skipping group message (pre-filter said no)" else do _ <- Memory.saveMessage uid chatId Memory.UserRole (Just userName) userMessage (conversationContext, contextTokens) <- Memory.getConversationContext uid chatId maxConversationTokens putText <| "Conversation context: " <> tshow contextTokens <> " tokens" processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMessage conversationContext processEngagedMessage :: Types.TelegramConfig -> Provider.Provider -> Engine.EngineConfig -> Types.TelegramMessage -> Text -> Text -> Int -> Text -> Text -> IO () processEngagedMessage tgConfig provider engineCfg msg uid userName chatId userMessage conversationContext = do memories <- Memory.recallMemories uid userMessage 5 let memoryContext = Memory.formatMemoriesForPrompt memories now <- getCurrentTime tz <- getCurrentTimeZone let localTime = utcToLocalTime tz now timeStr = Text.pack (formatTime defaultTimeLocale "%A, %B %d, %Y at %H:%M" localTime) let chatContext = if Types.isGroupChat msg then "\n\n## Chat Type\nThis is a GROUP CHAT. Apply the group response rules - only respond if appropriate." else "\n\n## Chat Type\nThis is a PRIVATE CHAT. Always respond to the user." systemPrompt = telegramSystemPrompt <> "\n\n## Current Date and Time\n" <> timeStr <> chatContext <> "\n\n## Current User\n" <> "You are talking to: " <> userName <> "\n\n## What you know about this user\n" <> memoryContext <> "\n\n" <> conversationContext let memoryTools = [ Memory.rememberTool uid, Memory.recallTool uid ] searchTools = case Types.tgKagiApiKey tgConfig of Just kagiKey -> [WebSearch.webSearchTool kagiKey] Nothing -> [] webReaderTools = [WebReader.webReaderTool (Types.tgOpenRouterApiKey tgConfig)] pdfTools = [Pdf.pdfTool] notesTools = [ Notes.noteAddTool uid, Notes.noteListTool uid, Notes.noteDeleteTool uid ] calendarTools = [ Calendar.calendarListTool, Calendar.calendarAddTool, Calendar.calendarSearchTool ] todoTools = [ Todos.todoAddTool uid, Todos.todoListTool uid, Todos.todoCompleteTool uid, Todos.todoDeleteTool uid ] tools = memoryTools <> searchTools <> webReaderTools <> pdfTools <> notesTools <> calendarTools <> todoTools let agentCfg = Engine.defaultAgentConfig { Engine.agentSystemPrompt = systemPrompt, Engine.agentTools = tools, Engine.agentMaxIterations = 5, Engine.agentGuardrails = Engine.defaultGuardrails { Engine.guardrailMaxCostCents = 10.0, Engine.guardrailMaxDuplicateToolCalls = 10 } } result <- Engine.runAgentWithProvider engineCfg provider agentCfg userMessage case result of Left err -> do putText <| "Agent error: " <> err sendMessage tgConfig chatId "Sorry, I encountered an error. Please try again." Right agentResult -> do let response = Engine.resultFinalMessage agentResult putText <| "Response text: " <> Text.take 200 response _ <- Memory.saveMessage uid chatId Memory.AssistantRole Nothing response if Text.null response then do if Types.isGroupChat msg then putText "Agent chose not to respond (group chat)" else do putText "Warning: empty response from agent" sendMessage tgConfig chatId "hmm, i don't have a response for that" else do sendMessage tgConfig chatId response checkAndSummarize (Types.tgOpenRouterApiKey tgConfig) uid chatId putText <| "Responded to " <> userName <> " (cost: " <> tshow (Engine.resultTotalCost agentResult) <> " cents)" maxConversationTokens :: Int maxConversationTokens = 4000 summarizationThreshold :: Int summarizationThreshold = 3000 checkAndSummarize :: Text -> Text -> Int -> IO () checkAndSummarize openRouterKey uid chatId = do (_, currentTokens) <- Memory.getConversationContext uid chatId maxConversationTokens when (currentTokens > summarizationThreshold) <| do putText <| "Context at " <> tshow currentTokens <> " tokens, summarizing..." recentMsgs <- Memory.getRecentMessages uid chatId 50 let conversationText = Text.unlines [ (if Memory.cmRole m == Memory.UserRole then "User: " else "Assistant: ") <> Memory.cmContent m | m <- reverse recentMsgs ] gemini = Provider.defaultOpenRouter openRouterKey "google/gemini-2.0-flash-001" summaryResult <- Provider.chat gemini [] [ Provider.Message Provider.System "You are a conversation summarizer. Summarize the key points, decisions, and context from this conversation in 2-3 paragraphs. Focus on information that would be useful for continuing the conversation later." Nothing Nothing, Provider.Message Provider.User ("Summarize this conversation:\n\n" <> conversationText) Nothing Nothing ] case summaryResult of Left err -> putText <| "Summarization failed: " <> err Right summaryMsg -> do let summary = Provider.msgContent summaryMsg _ <- Memory.summarizeAndArchive uid chatId summary putText "Conversation summarized and archived (gemini)" shouldEngageInGroup :: Text -> Text -> IO Bool shouldEngageInGroup openRouterKey messageText = do let gemini = Provider.defaultOpenRouter openRouterKey "google/gemini-2.0-flash-001" result <- Provider.chat gemini [] [ Provider.Message Provider.System ( Text.unlines [ "You are a classifier that decides if an AI assistant named 'Ava' should respond to a message in a group chat.", "You may be given recent conversation context to help decide.", "Respond with ONLY 'yes' or 'no' (lowercase, nothing else).", "", "Say 'yes' if:", "- The message is a direct question Ava could answer", "- The message contains a factual error worth correcting", "- The message mentions Ava or asks for help", "- The message shares a link or document to analyze", "- The message is a follow-up to a conversation Ava was just participating in", "- The user is clearly talking to Ava based on context (e.g. Ava just responded)", "", "Say 'no' if:", "- It's casual banter or chit-chat between people (not involving Ava)", "- It's a greeting or farewell not directed at Ava", "- It's an inside joke or personal conversation between humans", "- It doesn't require or benefit from Ava's input" ] ) Nothing Nothing, Provider.Message Provider.User messageText Nothing Nothing ] case result of Left err -> do putText <| "Engagement check failed: " <> err pure True Right msg -> do let response = Text.toLower (Text.strip (Provider.msgContent msg)) pure (response == "yes" || response == "y") checkOllama :: IO (Either Text ()) checkOllama = do ollamaUrl <- fromMaybe "http://localhost:11434" "/api/tags" result <- try <| do req <- HTTP.parseRequest url HTTP.httpLBS req case result of Left (e :: SomeException) -> pure (Left ("Ollama not running: " <> tshow e)) 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 "models" obj of Just (Aeson.Array models) -> let names = [n | Aeson.Object m <- toList models, Just (Aeson.String n) <- [KeyMap.lookup "name" m]] hasNomic = any ("nomic-embed-text" `Text.isInfixOf`) names in if hasNomic then pure (Right ()) else pure (Left "nomic-embed-text model not found") _ -> pure (Left "Invalid Ollama response") _ -> pure (Left "Failed to parse Ollama response") else pure (Left ("Ollama HTTP error: " <> tshow status)) pullEmbeddingModel :: IO (Either Text ()) pullEmbeddingModel = do ollamaUrl <- fromMaybe "http://localhost:11434" "/api/pull" putText "Pulling nomic-embed-text model (this may take a few minutes)..." req0 <- HTTP.parseRequest url let body = Aeson.object ["name" .= ("nomic-embed-text" :: Text)] req = HTTP.setRequestMethod "POST" <| HTTP.setRequestHeader "Content-Type" ["application/json"] <| HTTP.setRequestBodyLBS (Aeson.encode body) <| HTTP.setRequestResponseTimeout (HTTPClient.responseTimeoutMicro (600 * 1000000)) <| req0 result <- try (HTTP.httpLBS req) case result of Left (e :: SomeException) -> pure (Left ("Failed to pull model: " <> tshow e)) Right response -> do let status = HTTP.getResponseStatusCode response if status >= 200 && status < 300 then do putText "nomic-embed-text model ready" pure (Right ()) else pure (Left ("Pull failed: HTTP " <> tshow status)) ensureOllama :: IO () ensureOllama = do checkResult <- checkOllama case checkResult of Right () -> putText "Ollama ready with nomic-embed-text" Left err | "not running" `Text.isInfixOf` err -> do putText <| "Error: " <> err putText "Please start Ollama: ollama serve" exitFailure | "not found" `Text.isInfixOf` err -> do putText "nomic-embed-text model not found, pulling..." pullResult <- pullEmbeddingModel case pullResult of Right () -> pure () Left pullErr -> do putText <| "Error: " <> pullErr exitFailure | otherwise -> do putText <| "Ollama error: " <> err exitFailure 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 ensureOllama allowedIds <- loadAllowedUserIds kagiKey <- fmap Text.pack do putText "Error: OPENROUTER_API_KEY not set" exitFailure Just key -> do let orKey = Text.pack key tgConfig = Types.defaultTelegramConfig token allowedIds kagiKey orKey provider = Provider.defaultOpenRouter orKey "anthropic/claude-sonnet-4.5" putText <| "Allowed user IDs: " <> tshow allowedIds putText <| "Kagi search: " <> if isJust kagiKey then "enabled" else "disabled" runTelegramBot tgConfig provider loadAllowedUserIds :: IO [Int] loadAllowedUserIds = do maybeIds <- lookupEnv "ALLOWED_TELEGRAM_USER_IDS" case maybeIds of Nothing -> pure [] Just "*" -> pure [] Just idsStr -> do let ids = mapMaybe (readMaybe <. Text.unpack <. Text.strip) (Text.splitOn "," (Text.pack idsStr)) pure ids