Current section
5 Versions
Jump to
Current section
5 Versions
Compare versions
11
files changed
+713
additions
-45
deletions
| @@ -14,6 +14,7 @@ Hotline gives you everything you need to build Telegram bots in Elixir — from | |
| 14 14 | | **Long-polling** | Built-in `Hotline.Poller` with offset tracking, 409/429 handling | |
| 15 15 | | **Webhooks** | `Hotline.Webhook` Plug with secret token verification | |
| 16 16 | | **Bot behaviour** | `use Hotline.Bot` for quick PubSub-driven bots | |
| 17 | + | **Conversation flows** | Declarative DSL for multi-step conversations with validation and branching | |
| 17 18 | | **Access control** | Restrict bots to specific user IDs via `allowed_ids` | |
| 18 19 | | **Streaming** | Lazy `Stream.resource` for IEx exploration | |
| 19 20 | | **Broadway** | Optional `Hotline.BroadwayProducer` for pipeline processing | |
| @@ -153,6 +154,112 @@ Hotline.ChatRegistry.get(7644580464) # lookup by chat_id | |
| 153 154 | Hotline.ChatRegistry.count() # total count |
| 154 155 | ``` |
| 155 156 | |
| 157 | + ## Conversation Flows |
| 158 | + |
| 159 | + Build multi-step conversations with the Flow DSL. Define steps declaratively, handle input with pattern matching, and let the Engine manage state per chat. |
| 160 | + |
| 161 | + ### Defining a Flow |
| 162 | + |
| 163 | + ```elixir |
| 164 | + defmodule MyBot.Flows.Registration do |
| 165 | + use Hotline.Flow |
| 166 | + |
| 167 | + step :name, prompt: "What's your name?" |
| 168 | + step :email, prompt: fn ctx -> "Thanks #{ctx.data.name}! What's your email?" end |
| 169 | + step :confirm, |
| 170 | + prompt: fn ctx -> "Confirm? Name: #{ctx.data.name}" end, |
| 171 | + keyboard: [[%{text: "Yes", callback_data: "yes"}, %{text: "No", callback_data: "no"}]] |
| 172 | + |
| 173 | + @impl true |
| 174 | + def handle_input(:name, %{message: %{text: name}}, _ctx) when byte_size(name) >= 2 do |
| 175 | + {:next, store: %{name: name}} |
| 176 | + end |
| 177 | + def handle_input(:name, _, _ctx), do: {:retry, "Name must be at least 2 characters."} |
| 178 | + |
| 179 | + def handle_input(:email, %{message: %{text: email}}, _ctx) do |
| 180 | + {:next, store: %{email: email}} |
| 181 | + end |
| 182 | + |
| 183 | + def handle_input(:confirm, %{callback_query: %{data: "yes"}}, _ctx), do: :done |
| 184 | + def handle_input(:confirm, %{callback_query: %{data: "no"}}, _ctx), do: {:goto, :name, reset: true} |
| 185 | + def handle_input(:confirm, _, _ctx), do: {:retry, "Use the buttons."} |
| 186 | + |
| 187 | + @impl true |
| 188 | + def on_done(ctx) do |
| 189 | + Hotline.send_message(%{chat_id: ctx.chat_id, text: "Registered: #{ctx.data.name}"}) |
| 190 | + end |
| 191 | + end |
| 192 | + ``` |
| 193 | + |
| 194 | + `handle_input/3` return values control the flow: |
| 195 | + |
| 196 | + | Return | Effect | |
| 197 | + |--------|--------| |
| 198 | + | `{:next, store: %{k: v}}` | Merge data and advance to next step | |
| 199 | + | `:next` | Advance without storing data | |
| 200 | + | `{:goto, :step}` | Jump to a named step | |
| 201 | + | `{:goto, :step, reset: true}` | Jump and clear accumulated data | |
| 202 | + | `{:retry, "message"}` | Stay on current step, send error message | |
| 203 | + | `:done` / `{:done, result}` | Complete the flow | |
| 204 | + | `:cancel` | Cancel the flow | |
| 205 | + |
| 206 | + ### Running Flows |
| 207 | + |
| 208 | + Add `Hotline.Flow.Engine` to your supervision tree and trigger flows from your bot: |
| 209 | + |
| 210 | + ```elixir |
| 211 | + children = [ |
| 212 | + {Hotline.Poller, []}, |
| 213 | + {Hotline.Flow.Engine, []}, |
| 214 | + {MyBot, []} |
| 215 | + ] |
| 216 | + ``` |
| 217 | + |
| 218 | + ```elixir |
| 219 | + defmodule MyBot do |
| 220 | + use Hotline.Bot |
| 221 | + |
| 222 | + @impl Hotline.Bot |
| 223 | + def handle_update(%{message: %{text: "/register", chat: %{id: chat_id}}}, state) do |
| 224 | + Hotline.Flow.Engine.start_flow(chat_id, MyBot.Flows.Registration) |
| 225 | + {:noreply, state} |
| 226 | + end |
| 227 | + |
| 228 | + def handle_update(%{message: %{text: "/cancel", chat: %{id: chat_id}}}, state) do |
| 229 | + Hotline.Flow.Engine.cancel_flow(chat_id) |
| 230 | + {:noreply, state} |
| 231 | + end |
| 232 | + |
| 233 | + def handle_update(%{message: %{text: text, chat: %{id: chat_id}}} = update, state) |
| 234 | + when is_binary(text) do |
| 235 | + unless Hotline.Flow.Engine.handles_update?(update) do |
| 236 | + Hotline.send_message(%{chat_id: chat_id, text: "Try /register or /help"}) |
| 237 | + end |
| 238 | + {:noreply, state} |
| 239 | + end |
| 240 | + |
| 241 | + def handle_update(_update, state), do: {:noreply, state} |
| 242 | + end |
| 243 | + ``` |
| 244 | + |
| 245 | + ### Inline Keyboards in Flows |
| 246 | + |
| 247 | + Pass `keyboard:` to any step to send inline buttons with the prompt: |
| 248 | + |
| 249 | + ```elixir |
| 250 | + step :rating, |
| 251 | + prompt: "Rate your experience:", |
| 252 | + keyboard: [ |
| 253 | + [%{text: "1", callback_data: "1"}, %{text: "2", callback_data: "2"}, |
| 254 | + %{text: "3", callback_data: "3"}, %{text: "4", callback_data: "4"}, |
| 255 | + %{text: "5", callback_data: "5"}] |
| 256 | + ] |
| 257 | + |
| 258 | + def handle_input(:rating, %{callback_query: %{data: rating}}, _ctx) do |
| 259 | + {:next, store: %{rating: String.to_integer(rating)}} |
| 260 | + end |
| 261 | + ``` |
| 262 | + |
| 156 263 | ## Webhooks |
| 157 264 | |
| 158 265 | Use `Hotline.Webhook` as a Plug, or deploy standalone with Bandit: |
| @@ -200,6 +307,7 @@ See the [`examples/`](examples/) directory: | |
| 200 307 | |---------|-------------| |
| 201 308 | | [`echo_bot.exs`](examples/echo_bot.exs) | Echoes back whatever the user sends | |
| 202 309 | | [`greeter_bot.exs`](examples/greeter_bot.exs) | Handles `/start`, `/help`, `/ping`, `/whoami` commands | |
| 310 | + | [`flow_bot.exs`](examples/flow_bot.exs) | Multi-step flows: registration, feedback, and settings | |
| 203 311 | | [`stream_logger.exs`](examples/stream_logger.exs) | Logs incoming updates to the console via streaming | |
| 204 312 | | [`broadway_pipeline.exs`](examples/broadway_pipeline.exs) | Process updates through a Broadway pipeline | |
| @@ -1,23 +1,25 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/nyo16/hotline">>}]}. |
| 2 2 | {<<"name">>,<<"hotline">>}. |
| 3 | - {<<"version">>,<<"0.1.0">>}. |
| 3 | + {<<"version">>,<<"0.1.5">>}. |
| 4 4 | {<<"description">>,<<"Telegram Bot API client and framework for Elixir">>}. |
| 5 5 | {<<"elixir">>,<<"~> 1.18">>}. |
| 6 6 | {<<"app">>,<<"hotline">>}. |
| 7 7 | {<<"licenses">>,[<<"MIT">>]}. |
| 8 8 | {<<"files">>, |
| 9 | - [<<"lib">>,<<"lib/hotline.ex">>,<<"lib/hotline">>, |
| 10 | - <<"lib/hotline/client.ex">>,<<"lib/hotline/types">>, |
| 11 | - <<"lib/hotline/types/message.ex">>,<<"lib/hotline/types/update.ex">>, |
| 12 | - <<"lib/hotline/types/user.ex">>,<<"lib/hotline/types/callback_query.ex">>, |
| 13 | - <<"lib/hotline/types/chat.ex">>,<<"lib/hotline/stream.ex">>, |
| 14 | - <<"lib/hotline/type.ex">>,<<"lib/hotline/config.ex">>, |
| 15 | - <<"lib/hotline/error.ex">>,<<"lib/hotline/webhook.ex">>, |
| 16 | - <<"lib/hotline/broadway_producer.ex">>,<<"lib/hotline/application.ex">>, |
| 17 | - <<"lib/hotline/bot.ex">>,<<"lib/hotline/chat_registry.ex">>, |
| 18 | - <<"lib/hotline/poller.ex">>,<<"priv">>,<<"priv/generator">>, |
| 19 | - <<"priv/generator/generate.exs">>,<<".formatter.exs">>,<<"mix.exs">>, |
| 20 | - <<"README.md">>,<<"LICENSE">>,<<"CHANGELOG.md">>]}. |
| 9 | + [<<"lib">>,<<"lib/hotline">>,<<"lib/hotline/flow.ex">>, |
| 10 | + <<"lib/hotline/client.ex">>,<<"lib/hotline/error.ex">>, |
| 11 | + <<"lib/hotline/type.ex">>,<<"lib/hotline/flow">>, |
| 12 | + <<"lib/hotline/flow/context.ex">>,<<"lib/hotline/flow/runner.ex">>, |
| 13 | + <<"lib/hotline/flow/engine.ex">>,<<"lib/hotline/broadway_producer.ex">>, |
| 14 | + <<"lib/hotline/application.ex">>,<<"lib/hotline/webhook.ex">>, |
| 15 | + <<"lib/hotline/types">>,<<"lib/hotline/types/chat.ex">>, |
| 16 | + <<"lib/hotline/types/update.ex">>,<<"lib/hotline/types/message.ex">>, |
| 17 | + <<"lib/hotline/types/callback_query.ex">>,<<"lib/hotline/types/user.ex">>, |
| 18 | + <<"lib/hotline/poller.ex">>,<<"lib/hotline/chat_registry.ex">>, |
| 19 | + <<"lib/hotline/stream.ex">>,<<"lib/hotline/config.ex">>, |
| 20 | + <<"lib/hotline/bot.ex">>,<<"lib/hotline.ex">>,<<"priv">>, |
| 21 | + <<"priv/generator">>,<<"priv/generator/generate.exs">>,<<".formatter.exs">>, |
| 22 | + <<"mix.exs">>,<<"README.md">>,<<"LICENSE">>,<<"CHANGELOG.md">>]}. |
| 21 23 | {<<"requirements">>, |
| 22 24 | [[{<<"name">>,<<"req">>}, |
| 23 25 | {<<"app">>,<<"req">>}, |
| @@ -0,0 +1,139 @@ | |
| 1 | + defmodule Hotline.Flow do |
| 2 | + @moduledoc """ |
| 3 | + DSL and behaviour for building multi-step conversation flows. |
| 4 | + |
| 5 | + A flow is a module that declares a sequence of steps. Each step has a prompt |
| 6 | + (sent to the user) and the module implements `handle_input/3` to process |
| 7 | + responses and control transitions. |
| 8 | + |
| 9 | + ## Example |
| 10 | + |
| 11 | + defmodule MyBot.Flows.Registration do |
| 12 | + use Hotline.Flow |
| 13 | + |
| 14 | + step :name, prompt: "What's your name?" |
| 15 | + step :email, prompt: fn ctx -> "Thanks \#{ctx.data.name}! What's your email?" end |
| 16 | + step :confirm, |
| 17 | + prompt: fn ctx -> |
| 18 | + "Confirm?\\nName: \#{ctx.data.name}\\nEmail: \#{ctx.data[:email] || "skipped"}" |
| 19 | + end, |
| 20 | + keyboard: [[%{text: "Yes", callback_data: "yes"}, %{text: "No", callback_data: "no"}]] |
| 21 | + |
| 22 | + @impl true |
| 23 | + def handle_input(:name, %{message: %{text: name}}, _ctx) when byte_size(name) >= 2 do |
| 24 | + {:next, store: %{name: name}} |
| 25 | + end |
| 26 | + def handle_input(:name, _, _ctx), do: {:retry, "Name must be at least 2 characters."} |
| 27 | + |
| 28 | + def handle_input(:email, %{message: %{text: "/skip"}}, _ctx), do: {:next, store: %{email: nil}} |
| 29 | + def handle_input(:email, %{message: %{text: email}}, _ctx) do |
| 30 | + if String.contains?(email, "@"), |
| 31 | + do: {:next, store: %{email: email}}, |
| 32 | + else: {:retry, "Invalid email. Try again or /skip."} |
| 33 | + end |
| 34 | + |
| 35 | + def handle_input(:confirm, %{callback_query: %{data: "yes"}}, _ctx), do: :done |
| 36 | + def handle_input(:confirm, %{callback_query: %{data: "no"}}, _ctx), do: {:goto, :name, reset: true} |
| 37 | + def handle_input(:confirm, _, _ctx), do: {:retry, "Use the buttons above."} |
| 38 | + end |
| 39 | + |
| 40 | + ## Return Values |
| 41 | + |
| 42 | + `handle_input/3` must return one of: |
| 43 | + |
| 44 | + * `:next` — advance to the next step in sequence |
| 45 | + * `{:next, store: %{key: value}}` — merge data, then advance |
| 46 | + * `{:goto, step}` — jump to a named step |
| 47 | + * `{:goto, step, reset: true}` — jump and clear accumulated data |
| 48 | + * `{:retry, message}` — stay on the current step, send error message |
| 49 | + * `:done` — complete the flow |
| 50 | + * `{:done, result}` — complete with a result value |
| 51 | + * `:cancel` — cancel the flow |
| 52 | + |
| 53 | + ## Optional Callbacks |
| 54 | + |
| 55 | + * `on_done/1` — called when the flow completes (receives the final context) |
| 56 | + * `on_cancel/1` — called when the flow is cancelled |
| 57 | + |
| 58 | + ## Running Flows |
| 59 | + |
| 60 | + Use `Hotline.Flow.Engine` to manage active flows. See `Hotline.Flow.Runner` |
| 61 | + for the pure execution logic. |
| 62 | + """ |
| 63 | + |
| 64 | + alias Hotline.Flow.Context |
| 65 | + |
| 66 | + @type step_result :: |
| 67 | + :next |
| 68 | + | {:next, [{:store, map()}]} |
| 69 | + | {:goto, atom()} |
| 70 | + | {:goto, atom(), keyword()} |
| 71 | + | {:retry, String.t()} |
| 72 | + | :done |
| 73 | + | {:done, term()} |
| 74 | + | :cancel |
| 75 | + |
| 76 | + @callback handle_input(step :: atom(), update :: map(), ctx :: Context.t()) :: step_result() |
| 77 | + @callback on_done(ctx :: Context.t()) :: any() |
| 78 | + @callback on_cancel(ctx :: Context.t()) :: any() |
| 79 | + |
| 80 | + @optional_callbacks on_done: 1, on_cancel: 1 |
| 81 | + |
| 82 | + defmacro __using__(_opts) do |
| 83 | + quote do |
| 84 | + @behaviour Hotline.Flow |
| 85 | + Module.register_attribute(__MODULE__, :hotline_step_names, accumulate: true) |
| 86 | + @before_compile Hotline.Flow |
| 87 | + import Hotline.Flow, only: [step: 1, step: 2] |
| 88 | + end |
| 89 | + end |
| 90 | + |
| 91 | + @doc """ |
| 92 | + Declare a step in the flow. |
| 93 | + |
| 94 | + ## Options |
| 95 | + |
| 96 | + * `:prompt` — a string or `fn ctx -> string end` sent to the user when entering the step |
| 97 | + * `:keyboard` — inline keyboard markup (list of button rows) sent with the prompt |
| 98 | + """ |
| 99 | + defmacro step(name, opts \\ []) do |
| 100 | + prompt_clause = build_step_clause(:__prompt__, name, Keyword.get(opts, :prompt)) |
| 101 | + keyboard_clause = build_step_clause(:__keyboard__, name, Keyword.get(opts, :keyboard)) |
| 102 | + |
| 103 | + quote do |
| 104 | + @hotline_step_names unquote(name) |
| 105 | + unquote(prompt_clause) |
| 106 | + unquote(keyboard_clause) |
| 107 | + end |
| 108 | + end |
| 109 | + |
| 110 | + defp build_step_clause(_fun, _name, nil), do: nil |
| 111 | + |
| 112 | + defp build_step_clause(fun, name, value) when is_binary(value) or is_list(value) do |
| 113 | + quote do |
| 114 | + def unquote(fun)(unquote(name), _flow_ctx), do: unquote(value) |
| 115 | + end |
| 116 | + end |
| 117 | + |
| 118 | + defp build_step_clause(fun, name, fn_ast) do |
| 119 | + quote do |
| 120 | + def unquote(fun)(unquote(name), flow_ctx), do: unquote(fn_ast).(flow_ctx) |
| 121 | + end |
| 122 | + end |
| 123 | + |
| 124 | + defmacro __before_compile__(env) do |
| 125 | + step_names = Module.get_attribute(env.module, :hotline_step_names) |> Enum.reverse() |
| 126 | + |
| 127 | + quote do |
| 128 | + def __steps__, do: unquote(step_names) |
| 129 | + |
| 130 | + def __prompt__(_step, _flow_ctx), do: nil |
| 131 | + def __keyboard__(_step, _flow_ctx), do: nil |
| 132 | + |
| 133 | + def on_done(_ctx), do: :ok |
| 134 | + def on_cancel(_ctx), do: :ok |
| 135 | + |
| 136 | + defoverridable on_done: 1, on_cancel: 1 |
| 137 | + end |
| 138 | + end |
| 139 | + end |
| @@ -0,0 +1,56 @@ | |
| 1 | + defmodule Hotline.Flow.Context do |
| 2 | + @moduledoc """ |
| 3 | + Flow execution context passed to prompt functions and `handle_input/3` callbacks. |
| 4 | + |
| 5 | + Holds the current step, accumulated data, and metadata for a running flow. |
| 6 | + |
| 7 | + ## Fields |
| 8 | + |
| 9 | + * `:flow_module` — the module implementing `Hotline.Flow` |
| 10 | + * `:chat_id` — Telegram chat ID this flow belongs to |
| 11 | + * `:step` — current step atom (e.g. `:name`, `:confirm`) |
| 12 | + * `:data` — accumulated data map, built up via `{:next, store: %{...}}` |
| 13 | + * `:opts` — user-provided options passed when starting the flow |
| 14 | + |
| 15 | + ## Example |
| 16 | + |
| 17 | + ctx = Context.new(MyFlow, 123) |
| 18 | + ctx = Context.store(ctx, %{name: "Alice"}) |
| 19 | + ctx.data.name |
| 20 | + #=> "Alice" |
| 21 | + """ |
| 22 | + |
| 23 | + defstruct [:flow_module, :chat_id, :step, data: %{}, opts: %{}] |
| 24 | + |
| 25 | + @type t :: %__MODULE__{ |
| 26 | + flow_module: module(), |
| 27 | + chat_id: integer(), |
| 28 | + step: atom() | nil, |
| 29 | + data: map(), |
| 30 | + opts: map() |
| 31 | + } |
| 32 | + |
| 33 | + @doc "Create a new context for a flow." |
| 34 | + @spec new(module(), integer(), map()) :: t() |
| 35 | + def new(flow_module, chat_id, opts \\ %{}) do |
| 36 | + %__MODULE__{ |
| 37 | + flow_module: flow_module, |
| 38 | + chat_id: chat_id, |
| 39 | + step: nil, |
| 40 | + data: %{}, |
| 41 | + opts: opts |
| 42 | + } |
| 43 | + end |
| 44 | + |
| 45 | + @doc "Merge new data into the context's accumulated data." |
| 46 | + @spec store(t(), map()) :: t() |
| 47 | + def store(%__MODULE__{data: data} = ctx, new_data) when is_map(new_data) do |
| 48 | + %{ctx | data: Map.merge(data, new_data)} |
| 49 | + end |
| 50 | + |
| 51 | + @doc "Move the context to a specific step." |
| 52 | + @spec move_to(t(), atom()) :: t() |
| 53 | + def move_to(%__MODULE__{} = ctx, step) when is_atom(step) do |
| 54 | + %{ctx | step: step} |
| 55 | + end |
| 56 | + end |
| @@ -0,0 +1,220 @@ | |
| 1 | + defmodule Hotline.Flow.Engine do |
| 2 | + @moduledoc """ |
| 3 | + GenServer that manages active conversation flows per chat. |
| 4 | + |
| 5 | + Subscribes to PubSub updates and routes them to the correct flow. |
| 6 | + Sends prompts and error messages back via the Telegram API. |
| 7 | + |
| 8 | + ## Supervision Tree |
| 9 | + |
| 10 | + Add the Engine before any bots that use flows: |
| 11 | + |
| 12 | + children = [ |
| 13 | + {Hotline.Poller, []}, |
| 14 | + {Hotline.Flow.Engine, []}, |
| 15 | + {MyBot, []} |
| 16 | + ] |
| 17 | + |
| 18 | + ## Starting Flows |
| 19 | + |
| 20 | + # From a Bot's handle_update/2: |
| 21 | + def handle_update(%{message: %{text: "/register", chat: %{id: chat_id}}}, state) do |
| 22 | + Hotline.Flow.Engine.start_flow(chat_id, MyBot.Flows.Registration) |
| 23 | + {:noreply, state} |
| 24 | + end |
| 25 | + |
| 26 | + ## Integration with Bots |
| 27 | + |
| 28 | + Use `handles_update?/1` to skip updates being handled by an active flow: |
| 29 | + |
| 30 | + def handle_update(update, state) do |
| 31 | + unless Hotline.Flow.Engine.handles_update?(update) do |
| 32 | + # normal handling |
| 33 | + end |
| 34 | + {:noreply, state} |
| 35 | + end |
| 36 | + |
| 37 | + ## Testing |
| 38 | + |
| 39 | + Inject a `:sender` function to capture messages in tests: |
| 40 | + |
| 41 | + test_pid = self() |
| 42 | + sender = fn params, _opts -> send(test_pid, {:sent, params}); {:ok, %{}} end |
| 43 | + {:ok, engine} = Engine.start_link(sender: sender, name: :test_engine) |
| 44 | + """ |
| 45 | + |
| 46 | + use GenServer |
| 47 | + |
| 48 | + require Logger |
| 49 | + |
| 50 | + alias Hotline.Flow.Runner |
| 51 | + |
| 52 | + def start_link(opts \\ []) do |
| 53 | + name = opts[:name] || __MODULE__ |
| 54 | + GenServer.start_link(__MODULE__, opts, name: name) |
| 55 | + end |
| 56 | + |
| 57 | + @doc "Start a flow for a chat. Returns `{:error, :flow_active}` if one is already running." |
| 58 | + @spec start_flow(integer(), module(), map(), GenServer.server()) :: :ok | {:error, :flow_active} |
| 59 | + def start_flow(chat_id, flow_module, opts \\ %{}, engine \\ __MODULE__) do |
| 60 | + GenServer.call(engine, {:start_flow, chat_id, flow_module, opts}) |
| 61 | + end |
| 62 | + |
| 63 | + @doc "Cancel the active flow for a chat." |
| 64 | + @spec cancel_flow(integer(), GenServer.server()) :: :ok | {:error, :no_flow} |
| 65 | + def cancel_flow(chat_id, engine \\ __MODULE__) do |
| 66 | + GenServer.call(engine, {:cancel_flow, chat_id}) |
| 67 | + end |
| 68 | + |
| 69 | + @doc "Check if a chat has an active flow." |
| 70 | + @spec active_flow?(integer(), GenServer.server()) :: boolean() |
| 71 | + def active_flow?(chat_id, engine \\ __MODULE__) do |
| 72 | + GenServer.call(engine, {:active_flow?, chat_id}) |
| 73 | + end |
| 74 | + |
| 75 | + @doc "Check if an update belongs to a chat with an active flow." |
| 76 | + @spec handles_update?(map(), GenServer.server()) :: boolean() |
| 77 | + def handles_update?(update, engine \\ __MODULE__) do |
| 78 | + case extract_chat_id(update) do |
| 79 | + nil -> false |
| 80 | + chat_id -> active_flow?(chat_id, engine) |
| 81 | + end |
| 82 | + end |
| 83 | + |
| 84 | + # Server |
| 85 | + |
| 86 | + @impl true |
| 87 | + def init(opts) do |
| 88 | + Phoenix.PubSub.subscribe(Hotline.PubSub, "hotline:updates") |
| 89 | + |
| 90 | + sender = opts[:sender] || (&Hotline.send_message/2) |
| 91 | + |
| 92 | + {:ok, %{flows: %{}, opts: opts, sender: sender}} |
| 93 | + end |
| 94 | + |
| 95 | + @impl true |
| 96 | + def handle_call({:start_flow, chat_id, flow_module, flow_opts}, _from, state) do |
| 97 | + if Map.has_key?(state.flows, chat_id) do |
| 98 | + {:reply, {:error, :flow_active}, state} |
| 99 | + else |
| 100 | + {ctx, effects} = Runner.start(flow_module, chat_id, flow_opts) |
| 101 | + execute_effects(effects, state) |
| 102 | + state = put_or_remove_flow(state, chat_id, ctx, effects) |
| 103 | + {:reply, :ok, state} |
| 104 | + end |
| 105 | + end |
| 106 | + |
| 107 | + def handle_call({:cancel_flow, chat_id}, _from, state) do |
| 108 | + case Map.pop(state.flows, chat_id) do |
| 109 | + {nil, _} -> |
| 110 | + {:reply, {:error, :no_flow}, state} |
| 111 | + |
| 112 | + {ctx, flows} -> |
| 113 | + safe_callback(ctx.flow_module, :on_cancel, [ctx]) |
| 114 | + {:reply, :ok, %{state | flows: flows}} |
| 115 | + end |
| 116 | + end |
| 117 | + |
| 118 | + def handle_call({:active_flow?, chat_id}, _from, state) do |
| 119 | + {:reply, Map.has_key?(state.flows, chat_id), state} |
| 120 | + end |
| 121 | + |
| 122 | + @impl true |
| 123 | + def handle_info({:hotline_update, update}, state) do |
| 124 | + chat_id = extract_chat_id(update) |
| 125 | + |
| 126 | + case chat_id && Map.get(state.flows, chat_id) do |
| 127 | + nil -> |
| 128 | + {:noreply, state} |
| 129 | + |
| 130 | + ctx -> |
| 131 | + {new_ctx, effects} = |
| 132 | + try do |
| 133 | + Runner.handle_update(ctx, update) |
| 134 | + rescue |
| 135 | + e -> |
| 136 | + Logger.warning( |
| 137 | + "Flow #{inspect(ctx.flow_module)} crashed on step #{ctx.step}: #{Exception.message(e)}" |
| 138 | + ) |
| 139 | + |
| 140 | + safe_callback(ctx.flow_module, :on_cancel, [ctx]) |
| 141 | + {ctx, [{:cancel, ctx}]} |
| 142 | + end |
| 143 | + |
| 144 | + # Answer callback queries to remove loading spinner |
| 145 | + if update.callback_query do |
| 146 | + safe_answer_callback(update.callback_query, state) |
| 147 | + end |
| 148 | + |
| 149 | + execute_effects(effects, state) |
| 150 | + state = put_or_remove_flow(state, chat_id, new_ctx, effects) |
| 151 | + {:noreply, state} |
| 152 | + end |
| 153 | + end |
| 154 | + |
| 155 | + def handle_info(_msg, state), do: {:noreply, state} |
| 156 | + |
| 157 | + # Helpers |
| 158 | + |
| 159 | + defp put_or_remove_flow(state, chat_id, ctx, effects) do |
| 160 | + terminal? = |
| 161 | + Enum.any?(effects, fn |
| 162 | + {:done, _} -> true |
| 163 | + {:cancel, _} -> true |
| 164 | + _ -> false |
| 165 | + end) |
| 166 | + |
| 167 | + if terminal? do |
| 168 | + # Call on_done for :done effects |
| 169 | + for {:done, done_ctx} <- effects do |
| 170 | + safe_callback(done_ctx.flow_module, :on_done, [done_ctx]) |
| 171 | + end |
| 172 | + |
| 173 | + %{state | flows: Map.delete(state.flows, chat_id)} |
| 174 | + else |
| 175 | + %{state | flows: Map.put(state.flows, chat_id, ctx)} |
| 176 | + end |
| 177 | + end |
| 178 | + |
| 179 | + defp execute_effects(effects, state) do |
| 180 | + for effect <- effects do |
| 181 | + case effect do |
| 182 | + {:send_message, chat_id, text, nil} -> |
| 183 | + state.sender.(%{chat_id: chat_id, text: text}, state.opts) |
| 184 | + |
| 185 | + {:send_message, chat_id, text, keyboard} -> |
| 186 | + reply_markup = %{inline_keyboard: keyboard} |
| 187 | + state.sender.(%{chat_id: chat_id, text: text, reply_markup: reply_markup}, state.opts) |
| 188 | + |
| 189 | + _ -> |
| 190 | + :ok |
| 191 | + end |
| 192 | + end |
| 193 | + end |
| 194 | + |
| 195 | + defp safe_callback(module, function, args) do |
| 196 | + if function_exported?(module, function, length(args)) do |
| 197 | + apply(module, function, args) |
| 198 | + end |
| 199 | + rescue |
| 200 | + e -> |
| 201 | + Logger.warning("Flow callback #{function} failed: #{Exception.message(e)}") |
| 202 | + end |
| 203 | + |
| 204 | + defp safe_answer_callback(callback_query, state) do |
| 205 | + id = if is_map(callback_query), do: Map.get(callback_query, :id) |
| 206 | + |
| 207 | + if id do |
| 208 | + opts = state.opts |
| 209 | + Hotline.answer_callback_query(%{callback_query_id: id}, opts) |
| 210 | + end |
| 211 | + rescue |
| 212 | + _ -> :ok |
| 213 | + end |
| 214 | + |
| 215 | + @doc false |
| 216 | + def extract_chat_id(%{message: %{chat: %{id: id}}}), do: id |
| 217 | + def extract_chat_id(%{callback_query: %{message: %{chat: %{id: id}}}}), do: id |
| 218 | + def extract_chat_id(%{edited_message: %{chat: %{id: id}}}), do: id |
| 219 | + def extract_chat_id(_), do: nil |
| 220 | + end |
Loading more files…