Skip to content

BenefitWizard/telegram-bot-extra

Repository files navigation

telegram-bot-extra

A small Haskell library that gives telegram-bot-api bots three ways to receive Updates, plus Servant routing and command parsing:

  • Webhook endpoints via a Servant combinator keyed by a bot token (Telegram.Bot.Extra.BotRoute)
  • Long-polling of getUpdates (Telegram.Bot.Extra.Polling)
  • Headless queue-fed dispatch for tests, simulation, and replay (Telegram.Bot.Extra.Headless)
  • Attoparsec-based parsers for Telegram slash commands and arguments (Telegram.Bot.Extra.CommandParser)

All three runners take the same raw Update -> IO () action type and dispatch every update fire-and-forget in a linked background thread (the webhook answers 200 OK without waiting). They block forever; stop by cancelling the thread that runs them. The webhook and headless runners take no ClientEnv — you capture whatever you need (e.g. a Servant.Client.ClientEnv for telegram-bot-api) inside the action. Only runPollingBot takes a ClientEnv, because it must call getUpdates.

The library is built around telegram-bot-simple, telegram-bot-api, and Servant.

Modules

Telegram.Bot.Extra.BotRoute

This module provides a Servant combinator that takes a Token from the Servant context and turns it into a path segment.

The core API is:

fromToken :: Token -> ForToken a

type BotApi tokenType = ForToken tokenType :> ReqBody '[JSON] Update :> Post '[JSON] ()

botApi :: Proxy (BotApi tokenType)

server :: BotApp model action -> BotEnv model action -> Server (BotApi tokenType)

makeBotHandler :: MonadIO m => ClientEnv -> BotApp state update -> m (Update -> Handler ())

server handles incoming Telegram Update values and dispatches produced actions asynchronously through telegram-bot-simple.

makeBotHandler constructs a fresh BotEnv, starts the background action-processing loop, and returns a webhook handler function.

Example

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}

import Network.Wai (Application)
import Servant
import Telegram.Bot.API (Token)
import Telegram.Bot.Extra.BotRoute
import Telegram.Bot.Simple (BotApp)
import Telegram.Bot.Simple.BotApp.Internal (BotEnv)

type MyBotApi = BotApi Token

app :: Token -> BotApp model action -> BotEnv model action -> Application
app token botApp botEnv = serveWithContext botApi ctx (server botApp botEnv)
  where
    ctx = fromToken token :. EmptyContext

If the token is bot123456:ABC, the webhook endpoint generated by BotApi Token is:

POST /bot123456:ABC

The test suite also verifies that token-based routing composes with additional path segments, for example:

GET /bot123456:ABC/info

Raw handlers (bypass BotApp)

If you do not want to use the BotApp/BotEnv machinery from telegram-bot-simple, you can build a Servant Server (or a webhook handler) directly from a raw Update -> IO () action. Each update is dispatched fire-and-forget via asyncLink, so the webhook answers 200 OK immediately. Neither function takes a ClientEnv — capture it inside the action.

All three raw runners take an explicit onActionError :: SomeException -> IO () fallback: when the user action throws, this is invoked (log it, emit a metric, ignore it — your choice) and the exception is contained, so a single failing update never stops the runner. (By contrast telegram-bot-simple's startPolling hardcodes print-on-error.)

serverWithAction
  :: (SomeException -> IO ())  -- ^ invoked if the action throws
  -> (Update -> IO ())
  -> Server (BotApi tokenType)
makeRawBotHandler
  :: MonadIO m
  => (SomeException -> IO ())  -- ^ invoked if the action throws
  -> (Update -> IO ())
  -> m (Update -> Servant.Handler ())

serverWithAction builds the full Server for BotApi; makeRawBotHandler returns just the Update -> Handler () function, handy when you compose it into a larger Servant application yourself.

Example: a bot in an arbitrary monad

Reduce your handler to IO at the call site, capturing whatever it needs (here, a ClientEnv to send replies via telegram-bot-api):

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}

import Network.Wai (Application)
import Servant
import Servant.Client (ClientEnv)
import System.IO (hPutStrLn, stderr)
import Telegram.Bot.API (Token, Update)
import Telegram.Bot.Extra.BotRoute

-- Your handler can live in any monad, as long as you can run it down to IO.
myHandler :: ClientEnv -> Update -> IO ()
myHandler clientEnv update = do
  -- call telegram-bot-api directly here using clientEnv
  pure ()

-- Decide what happens when an action throws (here: log to stderr).
onActionError :: SomeException -> IO ()
onActionError e = hPutStrLn stderr ("action failed: " <> show e)

type MyBotApi = BotApi Token

app :: Token -> ClientEnv -> Application
app token clientEnv =
  serveWithContext botApi ctx (serverWithAction onActionError (myHandler clientEnv))
  where
    ctx = fromToken token :. EmptyContext

The same Update -> IO () action can also be fed to runPollingBot (long-poll source, needs the ClientEnv to call getUpdates) or runHeadlessBot (queue-fed source, no ClientEnv/network) — see those modules below.

Telegram.Bot.Extra.CommandParser

This module contains small Attoparsec parsers for Telegram slash commands.

Available helpers:

  • words :: Parser [Text] parses zero or more whitespace-separated words
  • botName :: Parser Text parses a bot mention suffix such as @myBot
  • command :: Text -> Parser Text parses a specific slash command like /start
  • commandArg :: Parser a -> Parser a parses a required argument after at least one space
  • mCommandArg :: Parser a -> Parser (Maybe a) parses an optional argument after at least one space
  • commandWithArg :: Text -> Parser Text parses a named command and returns the remaining text, or ""
  • commandWithMArg :: Text -> Parser (Maybe Text) parses a named command and returns the remaining text as Maybe
  • unknownCommand :: Parser Text parses any slash command and returns trailing text, or ""
  • unknownCommandWithArgs :: Parser Text parses any slash command and returns trailing text, or ""
  • parseTextUpdate :: p -> Parser p -> Text -> p runs a parser with a fallback value on parse failure

Example

{-# LANGUAGE OverloadedStrings #-}

import Prelude hiding (words)

import Data.Attoparsec.Text (Parser)
import Telegram.Bot.Extra.CommandParser

echoParser :: Parser Text
echoParser = commandWithArg "echo"

parseEcho :: Text -> Text
parseEcho = parseTextUpdate "unknown command" echoParser

Examples of supported input:

/start
/start@myBot
/echo hello telegram
/echo@myBot payload
/custom some trailing text

Telegram.Bot.Extra.Polling

This module provides a long-polling runner that calls getUpdates and dispatches each Update to the same raw Update -> IO () action type as the webhook runners, fire-and-forget via asyncLink. On a network error it sleeps for retryDelay microseconds and retries. It blocks forever; stop by cancelling the thread that runs it.

This is the only runner that takes a ClientEnv, because it must call getUpdates. It captures the ClientEnv so your action can call telegram-bot-api directly.

The core API is:

runPollingBot
  :: MonadIO m
  => (SomeException -> IO ())  -- ^ onActionError: invoked if the action throws
  -> Int                        -- ^ retryDelay: microseconds to sleep before retrying on a network error
  -> ClientEnv
  -> (Update -> IO ())
  -> m a
nextOffset :: [Update] -> Maybe UpdateId

nextOffset is the pure offset-advancement helper: max(updateUpdateId) + 1, or Nothing for an empty page. It is unit-tested and useful if you implement your own polling loop.

Example

import Servant.Client (ClientEnv)
import System.IO (hPutStrLn, stderr)
import Telegram.Bot.API (Update)
import Telegram.Bot.Extra.Polling

-- | runPollingBot owns the ClientEnv (needed for getUpdates) and passes
-- every update to the same Update -> IO () action type. Capture the
-- ClientEnv inside the action to send replies.
main :: ClientEnv -> IO ()
main clientEnv =
  runPollingBot onActionError 1000000 clientEnv $ \update ->
    myHandler clientEnv update
  where
    onActionError e = hPutStrLn stderr ("action failed: " <> show e)

Telegram.Bot.Extra.Headless

This module provides a queue-fed runner that reads Updates from a bounded Control.Concurrent.STM.TBQueue and dispatches each to the same raw Update -> IO () action, fire-and-forget via asyncLink. It needs no ClientEnv and no network — ideal for tests, simulation, and replay of recorded updates. It blocks forever (blocking on the empty queue, so no busy-wait); stop by cancelling the thread that runs it. The bounded queue gives the producer natural backpressure.

The core API is:

runHeadlessBot
  :: MonadIO m
  => (SomeException -> IO ())  -- ^ onActionError: invoked if the action throws
  -> TBQueue Update
  -> (Update -> IO ())
  -> m a
feedUpdates :: TBQueue Update -> [Update] -> IO ()

feedUpdates is the test/replay helper: it writes a list of Updates into the queue, one atomically per item.

Headless test recipe

Construct a TBQueue, run runHeadlessBot under withAsync, feedUpdates, and observe the dispatched update via an MVar with a timeout. The runner is auto-cancelled by withAsync when the block ends. No ClientEnv or network is involved.

import Control.Concurrent.Async (withAsync)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar)
import Control.Concurrent.STM (TBQueue, newTBQueueIO)
import Control.Exception (SomeException)
import System.Timeout (timeout)
import Telegram.Bot.API (Update)
import Telegram.Bot.Extra.Headless

headlessTest :: [Update] -> IO (Maybe Update)
headlessTest updates = do
  queue  <- newTBQueueIO 16
  result <- newEmptyMVar
  let onActionError :: SomeException -> IO ()
      onActionError _ = pure ()   -- ignore action errors in this test
  -- Run the bot in a background thread; withAsync cancels it on exit.
  -- Feed and observe INSIDE the block so the runner isn't cancelled
  -- before the fire-and-forget dispatch has a chance to run.
  withAsync
    (runHeadlessBot onActionError queue $ \upd -> putMVar result upd)
    $ \_ -> do
      feedUpdates queue updates
      -- Observe the dispatched update, with a bounded timeout
      -- so a broken dispatch fails instead of hanging.
      timeout 1000000 (readMVar result)

Installation

This repository currently builds as package telegram-bot-extra version 0.1.0.0.

If you consume it from Git, add the repository as a source dependency and pin the commit you want to use.

Stack example:

extra-deps:
  - git: https://github.com/BenefitWizard/telegram-bot-extra
    commit: <commit>

Dependencies

Library dependencies from the current package definition:

  • attoparsec
  • base >= 4.7 && < 5
  • servant
  • servant-client
  • servant-server
  • stm (GHC boot library; used by Telegram.Bot.Extra.Headless for TBQueue)
  • telegram-bot-api
  • telegram-bot-simple
  • text

Testing

Run the test suite with:

stack test

Current tests cover:

  • token-based route matching for ForToken
  • command parsing behavior for all helpers in Telegram.Bot.Extra.CommandParser
  • raw webhook handler (serverWithAction) returning 200 OK and dispatching the update
  • polling offset advancement (nextOffset)
  • headless queue-fed dispatch via runHeadlessBot / feedUpdates

License

BSD-3-Clause

About

Extra stuff for telegram-bot-simple

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors