ExGram

repository·master·Indexed 42 days ago

https://github.com/rockneurotiko/ex_gram

An Elixir library for building Telegram Bots featuring a high-level opinionated framework for rapid development and a low-level API for fine-grained control. It includes a DSL for responses, support for bot init hooks via the ExGram.BotInit behaviour, and integrated testing utilities via ExGram.Test.

Tokens
70.9K
Snippets
188
Records
252
Agent score
54%

What's inside ExGram

  1. How ExGram.Router dispatching works

    master

    The ExGram.Router uses a declarative scope / filter / handle DSL to replace manual handle/2 pattern matching.

    Dispatch Logic:

    1. Order Matters: Scopes are evaluated top-to-bottom in the order they are declared.
    2. AND Logic: All filters within a single scope must pass for the scope to match.
    3. First Match Wins: The first matching leaf scope executes its handler, and dispatching stops.
    4. Fallback: A scope defined without any filter matches everything. Place this at the bottom of your module to act as a catch-all fallback.

    Handler Arities:

    • 1-arity: Receives only the context. Use this for simple responses.
    • 2-arity: Receives (update_info, context). Use this when you need to extract data (like text or callback data) directly from the parsed update tuple.
    # 1-arity: receives only context
    def start(context) do
      answer(context, "Welcome!")
    end
    
    # 2-arity: receives (update_info, context)
    def echo({:text, text, _msg}, context) do
      answer(context, text)
    end
  2. How the ExGram DSL works

    master

    The ExGram DSL uses a builder pattern to queue actions. DSL functions do not execute immediately; instead, they build up a list of actions on the context object.

    Crucial Rules:

    1. Return the context: You must return the updated context from your handler. If you don't return it, the queued actions will never be executed.
    2. Immutability: Because Elixir is immutable, you must pass the updated context to each subsequent action in the chain (e.g., using the pipe operator |>).
    3. Execution Order: Actions are executed sequentially after your handler function completes (Action 1 $\rightarrow$ Action 2 $\rightarrow$ Action 3).
    4. Mixing DSL and Low-Level API: If you use the low-level ExGram.send_* functions inside a handler, they execute immediately, which might cause them to be sent before the DSL actions queued in the context. To run a low-level action after a DSL action, use on_result/2.
    def handle({:command, "start", _msg}, context) do
      context
      |> answer("Welcome!")           # Action 1: queued
      |> answer("Here's a menu:")     # Action 2: queued
      |> answer_photo(photo_id)       # Action 3: queued
    end
    # After handler returns, ExGram executes: action 1 $\rightarrow$ action 2 $\rightarrow$ action 3
  3. Configure bot handler_mode for testing

    master

    The handler_mode option determines how the dispatcher executes your bot's handler. This affects the behavior of ExGram.Test.push_update/2:

    • :sync: The handler runs inline within the dispatcher's process. push_update/2 blocks until the handler and all its API calls complete. This is the default when using ExGram.Test.start_bot/3.
    • :async: The handler is spawned in a separate process. push_update/2 returns immediately after the update is enqueued. This is the default in production environments.

    You can override the default mode in tests by passing the option to ExGram.Test.start_bot/3.

    # Force async mode (the production default) in a test
    {bot_name, _} = ExGram.Test.start_bot(context, MyApp.Bot, handler_mode: :async)
  4. Configure FSM state scoping with Key Adapters

    master

    Key adapters determine how FSM state is scoped (who shares state with whom). You can set this using the key: option in use ExGram.FSM.

    AdapterKeyUse case
    ExGram.FSM.Key.ChatUser (default){chat_id, user_id}Independent state per user per chat
    ExGram.FSM.Key.User{user_id}Same state across all chats (DMs, groups)
    ExGram.FSM.Key.Chat{chat_id}Shared state for all users in a chat
    ExGram.FSM.Key.ChatTopic{chat_id, thread_id}Shared state per forum topic
    ExGram.FSM.Key.ChatTopicUser{chat_id, thread_id, user_id}Per-user per forum topic

    Examples

    User-scoped (state follows user everywhere):

    use ExGram.FSM, key: ExGram.FSM.Key.User, flows: [...]

    Chat-scoped (shared state, e.g. group game sessions):

    use ExGram.FSM, key: ExGram.FSM.Key.Chat, flows: [...]
  5. ExGram Telemetry Overview

    master

    ExGram emits :telemetry events throughout its lifecycle, allowing integration with observability tools like Prometheus (via TelemetryMetrics), OpenTelemetry, or structured logging.

    OpenTelemetry Integration

    To automatically create OTel spans and traces with full context propagation across GenServer and spawn boundaries, use the OpentelemetryExGram library.

    Event Timing

    • Spans: Most events are spans. The :stop event carries a duration in :native time units.
    • Point-in-time: :shutdown events carry system_time.
    • Conversion: To convert native duration to milliseconds, use:
    System.convert_time_unit(duration, :native, :millisecond)
  6. Access hook data in handlers via context.extra

    master

    Any data returned as {:ok, map} from a Bot Init hook is merged into the extra_info map. This data is then accessible in every update handler via the context.extra field.

    defmodule MyApp.Bot do
      use ExGram.Bot, name: :my_bot
    
      on_bot_init(MyApp.AuthHook)
    
      command("status")
    
      def handle({:command, :status, _}, context) do
        # Accessing data set by AuthHook
        permissions = context.extra[:bot_permissions]
        answer(context, "Permissions: #{inspect(permissions)}")
      end
    end
  7. Halt the middleware chain

    master

    To stop the execution of subsequent middlewares and the final handler, set the :halted key to true in the context map. This is commonly used for authentication failures or rate limiting.

    Important distinction:

    • halted: true - Stops everything (the remaining middleware chain AND the handler).
    • middleware_halted: true - Stops the middleware chain, but allows the handler to execute.
    def call(context, _opts) do
      if rate_limited?(context) do
        context
        |> ExGram.Dsl.answer("⏱️ Please wait before sending another command")
        |> Map.put(:halted, true)
      else
        context
      end
    end
  8. How Bot Init Hooks work

    master

    Bot Init hooks allow you to run custom code during the bot's startup sequence, before it begins processing updates. They are ideal for tasks like fetching external configuration, pre-warming caches, or validating credentials.

    A hook is a module that implements the ExGram.BotInit behaviour. The on_bot_init/1 callback is executed once during startup. Hooks run in a specific sequence:

    1. ExGram.BotInit.GetMe (built-in, enabled by default)
    2. ExGram.BotInit.SetupCommands (built-in, enabled if setup_commands: true)
    3. Your custom hooks (in the order they are declared)
    4. The init/1 callback on your bot module
    5. The bot starts receiving updates

    Data can be passed between hooks and to all subsequent handlers using the context.extra map.

    defmodule MyApp.ConfigHook do
      @behaviour ExGram.BotInit
    
      @impl ExGram.BotInit
      def on_bot_init(opts) do
        # ... logic ...
        {:ok, %{app_config: config}}
      end
    end
  9. Understand the OpenTelemetry span hierarchy

    master

    ExGram automatically creates a nested span tree for updates. OTel context is propagated across process boundaries, including async handlers. A typical hierarchy follows this structure:

    • {prefix}.update (Root)
      • {prefix}.middleware (One per middleware)
      • {prefix}.handler
        • {prefix}.request (One per outbound API call)
  10. Define a conversation flow with ExGram.FSM.Flow

    master

    A conversation flow is a module that defines its own states and the valid transitions between them using ExGram.FSM.Flow.

    • Use use ExGram.FSM.Flow, name: :flow_name to identify the flow.
    • Use defstates to declare which states are valid and which states they can transition to.
    • Implement def default_state, do: :state_name to specify the starting point.

    Transitions are validated at compile time and enforced at runtime. If an invalid transition is attempted, the behavior is determined by the on_invalid_transition policy.

    defmodule MyBot.RegistrationFlow do
      use ExGram.FSM.Flow, name: :registration
    
      defstates do
        state :get_name,  to: [:get_email]
        state :get_email, to: [:done]
        state :done,      to: []
      end
    
      def default_state, do: :get_name
    end
  11. Implement custom filters

    master
    If the built-in filters do not meet your requirements (e.g., checking user roles, conversation state, or feature flags), you can implement the ExGram.Router.Filter behaviour. This allows you to encode any runtime predicate into your routing logic.
  12. Understand the `ExGram.Bot` module structure

    master

    When using ExGram.Bot, your module follows this pattern:

    • @bot :name - An internal identifier for your bot.
    • use ExGram.Bot, [...] - Imports the framework. Common options include setup_commands: true (automatically registers commands with Telegram).
    • command/1-2 - Declares commands (e.g., command("start") or command("help", description: "...")) that the bot is capable of handling.
    • middleware/1 - Adds middleware to the processing pipeline (e.g., middleware(ExGram.Middleware.IgnoreUsername)).
    • handle({:type, :name, _msg}, context) - The callback function used to process incoming updates (e.g., commands, messages).
    defmodule MyBot.Bot do
      @bot :my_bot
    
      use ExGram.Bot,
        name: @bot,
        setup_commands: true
    
      command("start")
      command("help", description: "Print the bot's help")
    
      middleware(ExGram.Middleware.IgnoreUsername)
    
      def handle({:command, :start, _msg}, context) do
        answer(context, "Hi!")
      end
    
      def handle({:command, :help, _msg}, context) do
        answer(context, "Here is your help:")
      end
    end