Packages

Telegram Bot API client and framework for Elixir

Current section

5 Versions

Jump to

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…