diff --git a/AGENTS.md b/AGENTS.md index a548f71..e99f2c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,14 +43,14 @@ The application includes an automatic dialog summarization system to optimize AI - **`Bodhi.Release`** (`lib/bodhi/release.ex`) - Release tasks including: - `backfill_summaries/1` - Migration tool for historical data (line ~26) - Supports dry-run, date ranges, and chat filtering -- **`Bodhi.TgWebhookHandler`** (`lib/bodhi/tg_webhook_handler.ex`) - Updated at line ~130 +- **`Bodhi.TgUpdateHandler`** (`lib/bodhi/tg_update_handler.ex`) - Updated at line ~130 - Uses `get_chat_context_for_ai/2` instead of `get_chat_messages/1` **Context Assembly:** - `Bodhi.Chats.get_chat_context_for_ai/2` assembles context from: - Summaries for messages older than 7 days (configurable) - Full messages from last 7 days -- Used by `TgWebhookHandler.get_answer/2` instead of `get_chat_messages/1` +- Used by `TgUpdateHandler.get_answer/2` instead of `get_chat_messages/1` - Gracefully falls back to recent messages when no summaries exist **Configuration:** @@ -73,6 +73,49 @@ config :bodhi, Oban, - Summaries are idempotent - safe to regenerate - Worker processes chats sequentially to respect rate limits - Backfill tool supports dry-run mode for cost estimation + +#### Telegram Bot Handlers + +Update delivery is split across three modules so the same +business logic runs under both polling (dev/test) and +webhook (production) transports: + +- **`Bodhi.TgUpdateHandler`** (`lib/bodhi/tg_update_handler.ex`) - + Shared update handling logic: parses the Telegram update, + routes commands, calls the LLM, and sends replies. Both + handlers below delegate to `on_update/1` here. +- **`Bodhi.TgPollingHandler`** (`lib/bodhi/tg_polling_handler.ex`) - + `Telegex.Polling.GenHandler` used in dev/test. Long-polls + Telegram for updates and forwards each to + `Bodhi.TgUpdateHandler.on_update/1`. +- **`Bodhi.TgHookHandler`** (`lib/bodhi/tg_hook_handler.ex`) - + GenServer used in production. On `init/1` it registers the + webhook URL (derived from `BodhiWeb.Endpoint.url/0`) via + `Bodhi.Telegram.delete_webhook/0` and + `Bodhi.Telegram.set_webhook/2`, and stops with + `{:webhook_setup_failed, reason}` if either call fails. + Updates arrive over HTTP via + `BodhiWeb.TelegramWebhookController` + (`POST /api/telegram/webhook`), authenticated by + `BodhiWeb.Plugs.TelegramWebhookAuth` comparing the + `X-Telegram-Bot-Api-Secret-Token` header against the + configured secret. + +**Configuration:** +```elixir +# config/config.exs / config/test.exs / config/runtime.exs +config :bodhi, :tg_mode, :polling # :polling | :webhook | :disabled + +# Only read when :tg_mode is :webhook (production) +config :bodhi, Bodhi.TgHookHandler, secret_token: "..." +``` +- `Bodhi.Application` starts `Bodhi.TgPollingHandler`, + `Bodhi.TgHookHandler`, or neither based on `:tg_mode` + (see `tg_handler/0` in `lib/bodhi/application.ex`). +- Webhook registration goes through `Bodhi.Telegram` (the + same `Bodhi.Behaviours.TelegramClient` indirection used + by every other Telegram call), so it can be mocked with + `Bodhi.TelegramMock` in tests. - Summary messages use `user_id: -1` as a special marker ## Project guidelines diff --git a/README.md b/README.md index 4bea839..21371b5 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,20 @@ Edit `lib/bodhi/open_router.ex` and modify the `@default_model` attribute: See all available models at: https://openrouter.ai/models +## Telegram Bot Configuration + +In dev/test, the bot receives updates by polling Telegram +and no extra configuration is required. In production +(`MIX_ENV=prod`), it switches to webhook mode and requires: + +- **Environment Variable:** `TG_WEBHOOK_SECRET` (required) + - Secret token Telegram must echo back on the + `X-Telegram-Bot-Api-Secret-Token` header of every + webhook request; requests without a matching token are + rejected with `401`. + - Set in `.envrc`: `export TG_WEBHOOK_SECRET=your_random_secret_here` + - The app raises on boot in production if this is unset. + ## Database Configuration By default, `dev` and `test` connect to the Postgres instance defined in diff --git a/config/config.exs b/config/config.exs index a286519..ff9a205 100644 --- a/config/config.exs +++ b/config/config.exs @@ -97,5 +97,6 @@ config :bodhi, Bodhi.Cache, config :bodhi, :telegram_client, Bodhi.Telegram.TelegexAdapter config :bodhi, :llm_provider, Bodhi.OpenRouter +config :bodhi, :tg_mode, :polling import_config "#{config_env()}.exs" diff --git a/config/runtime.exs b/config/runtime.exs index cd25887..e5e232c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -38,6 +38,14 @@ if config_env() in [:dev, :test] do end if config_env() == :prod do + config :bodhi, :tg_mode, :webhook + + secret_token = + System.get_env("TG_WEBHOOK_SECRET") || + raise "environment variable TG_WEBHOOK_SECRET is missing" + + config :bodhi, Bodhi.TgHookHandler, secret_token: secret_token + database_url = System.get_env("DATABASE_URL") || raise """ diff --git a/config/test.exs b/config/test.exs index fa06819..cdefaf5 100644 --- a/config/test.exs +++ b/config/test.exs @@ -45,3 +45,4 @@ config :bodhi, Bodhi.Cache, config :bodhi, :telegram_client, Bodhi.TelegramMock config :bodhi, :llm_provider, Bodhi.LLMMock +config :bodhi, :tg_mode, :disabled diff --git a/docs/SUMMARIZATION.md b/docs/SUMMARIZATION.md index 24d282c..3011e61 100644 --- a/docs/SUMMARIZATION.md +++ b/docs/SUMMARIZATION.md @@ -215,7 +215,7 @@ If issues arise: 2. Revert to old behavior: ```elixir - # In TgWebhookHandler + # In TgUpdateHandler defp get_answer(%_{chat_id: chat_id}, _) do messages = Bodhi.Chats.get_chat_messages(chat_id) # Old way {:ok, _answer} = Bodhi.AI.ask_llm(messages) diff --git a/lib/bodhi/application.ex b/lib/bodhi/application.ex index f6e499e..4d221e3 100644 --- a/lib/bodhi/application.ex +++ b/lib/bodhi/application.ex @@ -20,7 +20,7 @@ defmodule Bodhi.Application do {Phoenix.PubSub, name: Bodhi.PubSub}, # Start the Endpoint (http/https) BodhiWeb.Endpoint, - Bodhi.TgWebhookHandler, + tg_handler(), {Finch, name: LLM, pools: %{ @@ -29,12 +29,22 @@ defmodule Bodhi.Application do {Oban, Application.fetch_env!(:bodhi, Oban)} ] + children = Enum.reject(children, &is_nil/1) + # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Bodhi.Supervisor] Supervisor.start_link(children, opts) end + defp tg_handler do + case Application.get_env(:bodhi, :tg_mode, :polling) do + :webhook -> Bodhi.TgHookHandler + :polling -> Bodhi.TgPollingHandler + :disabled -> nil + end + end + # Tell Phoenix to update the endpoint configuration # whenever the application is updated. @spec config_change( diff --git a/lib/bodhi/behaviours/telegram_client.ex b/lib/bodhi/behaviours/telegram_client.ex index 543ee4e..f3593cc 100644 --- a/lib/bodhi/behaviours/telegram_client.ex +++ b/lib/bodhi/behaviours/telegram_client.ex @@ -32,4 +32,16 @@ defmodule Bodhi.Behaviours.TelegramClient do chat_id :: integer(), action :: String.t() ) :: {:ok, boolean()} | {:error, Telegex.Type.error()} + + @doc """ + Removes the currently configured webhook. + """ + @callback delete_webhook() :: + {:ok, boolean()} | {:error, Telegex.Type.error()} + + @doc """ + Registers a webhook URL to receive updates. + """ + @callback set_webhook(url :: String.t(), opts :: Keyword.t()) :: + {:ok, boolean()} | {:error, Telegex.Type.error()} end diff --git a/lib/bodhi/periodic_messages.ex b/lib/bodhi/periodic_messages.ex index da64575..41f4568 100644 --- a/lib/bodhi/periodic_messages.ex +++ b/lib/bodhi/periodic_messages.ex @@ -78,6 +78,6 @@ defmodule Bodhi.PeriodicMessages do prompt_id: prompt_id }) - Bodhi.TgWebhookHandler.send_message(chat_id, text) + Bodhi.TgUpdateHandler.send_message(chat_id, text) end end diff --git a/lib/bodhi/telegram.ex b/lib/bodhi/telegram.ex index ce5c975..7a07b3b 100644 --- a/lib/bodhi/telegram.ex +++ b/lib/bodhi/telegram.ex @@ -30,6 +30,22 @@ defmodule Bodhi.Telegram do impl().send_chat_action(chat_id, action) end + @doc """ + Removes the currently configured webhook. + """ + @impl true + def delete_webhook do + impl().delete_webhook() + end + + @doc """ + Registers a webhook URL to receive updates. + """ + @impl true + def set_webhook(url, opts \\ []) do + impl().set_webhook(url, opts) + end + defp impl do Application.get_env(:bodhi, :telegram_client, Bodhi.Telegram.TelegexAdapter) end diff --git a/lib/bodhi/telegram/telegex_adapter.ex b/lib/bodhi/telegram/telegex_adapter.ex index 4ac99bf..32acdca 100644 --- a/lib/bodhi/telegram/telegex_adapter.ex +++ b/lib/bodhi/telegram/telegex_adapter.ex @@ -19,4 +19,14 @@ defmodule Bodhi.Telegram.TelegexAdapter do def send_chat_action(chat_id, action) do Telegex.send_chat_action(chat_id, action) end + + @impl true + def delete_webhook do + Telegex.delete_webhook() + end + + @impl true + def set_webhook(url, opts) do + Telegex.set_webhook(url, opts) + end end diff --git a/lib/bodhi/tg_hook_handler.ex b/lib/bodhi/tg_hook_handler.ex new file mode 100644 index 0000000..42c3928 --- /dev/null +++ b/lib/bodhi/tg_hook_handler.ex @@ -0,0 +1,49 @@ +defmodule Bodhi.TgHookHandler do + @moduledoc """ + Registers the Telegram webhook on application start. + + The webhook URL is derived from `BodhiWeb.Endpoint.url/0` + combined with the Telegram webhook route. The secret + token is read from application config. The actual HTTP + handling is done by + `BodhiWeb.TelegramWebhookController`. + """ + use GenServer + + require Logger + + @webhook_path "/api/telegram/webhook" + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + @spec init(keyword()) :: + {:ok, map()} | {:stop, {:webhook_setup_failed, term()}} + def init(_opts) do + config = + Application.get_env(:bodhi, __MODULE__, []) + + webhook_url = BodhiWeb.Endpoint.url() <> @webhook_path + + with {:ok, true} <- Bodhi.Telegram.delete_webhook(), + {:ok, true} <- + Bodhi.Telegram.set_webhook(webhook_url, + secret_token: config[:secret_token] + ) do + Logger.info("Telegram webhook registered: #{webhook_url}") + + {:ok, %{}} + else + error -> + Logger.error( + "Failed to configure Telegram webhook: " <> + "#{inspect(error)}" + ) + + {:stop, {:webhook_setup_failed, error}} + end + end +end diff --git a/lib/bodhi/tg_polling_handler.ex b/lib/bodhi/tg_polling_handler.ex new file mode 100644 index 0000000..cce2cba --- /dev/null +++ b/lib/bodhi/tg_polling_handler.ex @@ -0,0 +1,19 @@ +defmodule Bodhi.TgPollingHandler do + @moduledoc """ + Telegram polling handler for dev/test environments. + Delegates update processing to `Bodhi.TgUpdateHandler`. + """ + use Telegex.Polling.GenHandler + + @impl true + @spec on_boot :: Telegex.Polling.Config.t() + def on_boot do + %Telegex.Polling.Config{} + end + + @impl true + @spec on_update(Telegex.Type.Update.t()) :: :ok + def on_update(update) do + Bodhi.TgUpdateHandler.on_update(update) + end +end diff --git a/lib/bodhi/tg_webhook_handler.ex b/lib/bodhi/tg_update_handler.ex similarity index 75% rename from lib/bodhi/tg_webhook_handler.ex rename to lib/bodhi/tg_update_handler.ex index dcadd2b..0f12cbb 100644 --- a/lib/bodhi/tg_webhook_handler.ex +++ b/lib/bodhi/tg_update_handler.ex @@ -1,8 +1,10 @@ -defmodule Bodhi.TgWebhookHandler do +defmodule Bodhi.TgUpdateHandler do @moduledoc """ - Telegram Bot API handler + Telegram Bot update handler — shared business logic. + + Called by `Bodhi.TgPollingHandler` (dev/test) and + `Bodhi.TgHookHandler` (production). """ - use Telegex.Polling.GenHandler use BodhiWeb, :verified_routes require Logger @@ -11,52 +13,53 @@ defmodule Bodhi.TgWebhookHandler do alias Bodhi.Prompts.Prompt alias Telegex.Type.{Message, MessageEntity, Update} - @impl true - @spec on_boot :: Telegex.Polling.Config.t() - def on_boot do - # env_config = Application.get_env(:bodhi, __MODULE__) - # delete the webhook and set it again - # unless Mix.env() == :test do - # {:ok, true} = Telegex.delete_webhook() - # end - - # {:ok, bot_user} = Telegex.get_me() - # Bodhi.Users.create_or_update_user(bot_user) - # set the webhook (url is required) - # {:ok, true} = Telegex.set_webhook(env_config[:webhook_url]) - # specify port for web server - # port has a default value of 4000, but it may change with library upgrades - # %Telegex.Hook.Config{server_port: env_config[:server_port]} - %Telegex.Polling.Config{} - end - - @impl true @spec on_update(Update.t()) :: :ok def on_update(update) do Logger.debug( - "Update received: #{inspect(update, pretty: true, printable_limit: :infinity, limit: :infinity)}" + "Update received: " <> + inspect(update, + pretty: true, + printable_limit: :infinity, + limit: :infinity + ) ) handle_update(update) :ok end - defp handle_update(%Update{message: message}) when not is_nil(message) do + defp handle_update(%Update{message: message}) + when not is_nil(message) do handle_message(message) end defp handle_update(%Update{} = update) do Logger.warning( - "Unhandled update: #{inspect(update, pretty: true, printable_limit: :infinity, limit: :infinity)}" + "Unhandled update: " <> + inspect(update, + pretty: true, + printable_limit: :infinity, + limit: :infinity + ) ) end # Login URL is plain text — sent without parse_mode # to avoid HTML-escaping the URL query string. - defp handle_message(%Message{text: "/login", entities: _entities, from: user, chat: chat}) do + defp handle_message(%Message{ + text: "/login", + entities: _entities, + from: user, + chat: chat + }) do with db_user <- Bodhi.Users.get_user!(user.id), true <- db_user.is_admin, - token <- Phoenix.Token.sign(BodhiWeb.Endpoint, "user auth", db_user.id), + token <- + Phoenix.Token.sign( + BodhiWeb.Endpoint, + "user auth", + db_user.id + ), url <- url(~p"/login?#{[token: token]}") do Bodhi.Telegram.send_message(chat.id, url) else @@ -70,7 +73,11 @@ defmodule Bodhi.TgWebhookHandler do handle_message(%{message | entities: []}) end - defp handle_message(%Message{entities: [%MessageEntity{type: "bot_command"}]} = message) do + defp handle_message( + %Message{ + entities: [%MessageEntity{type: "bot_command"}] + } = message + ) do Logger.info("Bot command: #{inspect(message, pretty: true)}") end @@ -78,9 +85,15 @@ defmodule Bodhi.TgWebhookHandler do with {:ok, user} <- Bodhi.Users.create_or_update_user(user), {:ok, chat} <- save_chat(chat, user), {:ok, message} <- save_message(message, chat.id, user), - {:ok, answer, metadata} <- get_answer(message, user.language_code), - {:ok, _answer_msg} <- send_message(chat.id, answer, metadata) do - Bodhi.PeriodicMessages.create_for_new_user(:followup, {1, :days}, chat.id) + {:ok, answer, metadata} <- + get_answer(message, user.language_code), + {:ok, _answer_msg} <- + send_message(chat.id, answer, metadata) do + Bodhi.PeriodicMessages.create_for_new_user( + :followup, + {1, :days}, + chat.id + ) PostHog.capture("message_handled", %{ distinct_id: user.id, @@ -94,6 +107,9 @@ defmodule Bodhi.TgWebhookHandler do # Returns {:ok, %Chats.Message{}} or {:error, _}, # not the raw Telegex message. + @spec send_message(integer(), String.t(), map()) :: + {:ok, Bodhi.Chats.Message.t() | nil} + | {:error, term()} def send_message(chat_id, text, metadata \\ %{}) do {chunks, opts} = Bodhi.Telegram.Formatter.format_chunks(text) @@ -118,7 +134,13 @@ defmodule Bodhi.TgWebhookHandler do |> extract_result() end - defp send_chunk(chat_id, opts, metadata, chunk, {_result, llm_id}) do + defp send_chunk( + chat_id, + opts, + metadata, + chunk, + {_result, llm_id} + ) do case Bodhi.Telegram.send_message(chat_id, chunk, opts) do {:ok, message} -> llm_id = diff --git a/lib/bodhi_web/controllers/telegram_webhook_controller.ex b/lib/bodhi_web/controllers/telegram_webhook_controller.ex new file mode 100644 index 0000000..c98108a --- /dev/null +++ b/lib/bodhi_web/controllers/telegram_webhook_controller.ex @@ -0,0 +1,21 @@ +defmodule BodhiWeb.TelegramWebhookController do + @moduledoc """ + Receives Telegram webhook updates via Phoenix routes. + + Authentication is performed by + `BodhiWeb.Plugs.TelegramWebhookAuth` in the router. + This action parses the update and delegates to + `Bodhi.TgUpdateHandler`. + """ + use BodhiWeb, :controller + + @spec webhook(Plug.Conn.t(), map()) :: Plug.Conn.t() + def webhook(conn, params) do + update = + Telegex.Helper.typedmap(params, Telegex.Type.Update) + + Bodhi.TgUpdateHandler.on_update(update) + + json(conn, %{}) + end +end diff --git a/lib/bodhi_web/plug/telegram_webhook_auth.ex b/lib/bodhi_web/plug/telegram_webhook_auth.ex new file mode 100644 index 0000000..fbb3b90 --- /dev/null +++ b/lib/bodhi_web/plug/telegram_webhook_auth.ex @@ -0,0 +1,56 @@ +defmodule BodhiWeb.Plugs.TelegramWebhookAuth do + @moduledoc """ + Validates the secret token on Telegram webhook requests. + + Compares the value of the + `X-Telegram-Bot-Api-Secret-Token` header against the + `:secret_token` configured for `Bodhi.TgHookHandler`. + + When the token does not match, the request is halted with + a 401 response and a warning is logged. + """ + import Plug.Conn + + require Logger + + @secret_header "x-telegram-bot-api-secret-token" + + @spec init(Keyword.t()) :: Keyword.t() + def init(opts), do: opts + + @spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() + def call(conn, _opts) do + if authorized?(conn, expected_token()) do + conn + else + Logger.warning( + "Unauthorized Telegram webhook request from " <> + "`#{remote_ip(conn)}`" + ) + + conn + |> send_resp(:unauthorized, "") + |> halt() + end + end + + defp expected_token do + :bodhi + |> Application.get_env(Bodhi.TgHookHandler, []) + |> Keyword.get(:secret_token) + end + + defp authorized?(conn, secret_token) do + case get_req_header(conn, @secret_header) do + [] -> is_nil(secret_token) + tokens -> List.last(tokens) == secret_token + end + end + + defp remote_ip(%{remote_ip: ip_tuple}) + when not is_nil(ip_tuple) do + :inet.ntoa(ip_tuple) + end + + defp remote_ip(_conn), do: "[unknown_ip]" +end diff --git a/lib/bodhi_web/router.ex b/lib/bodhi_web/router.ex index 00a1fae..a2d692b 100644 --- a/lib/bodhi_web/router.ex +++ b/lib/bodhi_web/router.ex @@ -19,6 +19,10 @@ defmodule BodhiWeb.Router do plug BodhiWeb.Plugs.Auth end + pipeline :telegram_webhook do + plug BodhiWeb.Plugs.TelegramWebhookAuth + end + scope "/", BodhiWeb do pipe_through [:browser, :auth] @@ -56,10 +60,11 @@ defmodule BodhiWeb.Router do get "/p/:slug", PageController, :page end - # Other scopes may use custom stacks. - # scope "/api", BodhiWeb do - # pipe_through :api - # end + scope "/api/telegram", BodhiWeb do + pipe_through [:api, :telegram_webhook] + + post "/webhook", TelegramWebhookController, :webhook + end # Enables LiveDashboard only for development # diff --git a/test/bodhi/tg_hook_handler_test.exs b/test/bodhi/tg_hook_handler_test.exs new file mode 100644 index 0000000..8ec6946 --- /dev/null +++ b/test/bodhi/tg_hook_handler_test.exs @@ -0,0 +1,44 @@ +defmodule Bodhi.TgHookHandlerTest do + use ExUnit.Case, async: false + + import Mox + + alias Bodhi.TgHookHandler + + setup :set_mox_global + setup :verify_on_exit! + + describe "init/1" do + test "registers the webhook and starts successfully" do + expect(Bodhi.TelegramMock, :delete_webhook, fn -> {:ok, true} end) + + expect(Bodhi.TelegramMock, :set_webhook, fn _url, _opts -> + {:ok, true} + end) + + pid = start_supervised!(TgHookHandler) + + assert %{} = :sys.get_state(pid) + end + + test "stops when delete_webhook fails" do + expect(Bodhi.TelegramMock, :delete_webhook, fn -> + {:error, %Telegex.Error{error_code: 500, description: "boom"}} + end) + + assert {:error, {{:webhook_setup_failed, _reason}, _child_spec}} = + start_supervised(TgHookHandler) + end + + test "stops when set_webhook fails" do + expect(Bodhi.TelegramMock, :delete_webhook, fn -> {:ok, true} end) + + expect(Bodhi.TelegramMock, :set_webhook, fn _url, _opts -> + {:error, %Telegex.Error{error_code: 500, description: "boom"}} + end) + + assert {:error, {{:webhook_setup_failed, _reason}, _child_spec}} = + start_supervised(TgHookHandler) + end + end +end diff --git a/test/bodhi/tg_polling_handler_test.exs b/test/bodhi/tg_polling_handler_test.exs new file mode 100644 index 0000000..515483d --- /dev/null +++ b/test/bodhi/tg_polling_handler_test.exs @@ -0,0 +1,20 @@ +defmodule Bodhi.TgPollingHandlerTest do + use ExUnit.Case, async: true + + alias Bodhi.TgPollingHandler + alias Telegex.Type.Update + + describe "on_boot/0" do + test "returns a polling config" do + assert %Telegex.Polling.Config{} = TgPollingHandler.on_boot() + end + end + + describe "on_update/1" do + test "delegates to Bodhi.TgUpdateHandler" do + update = %Update{update_id: Faker.random_bytes(100)} + + assert :ok == TgPollingHandler.on_update(update) + end + end +end diff --git a/test/bodhi/tg_webhook_handler_test.exs b/test/bodhi/tg_update_handler_test.exs similarity index 95% rename from test/bodhi/tg_webhook_handler_test.exs rename to test/bodhi/tg_update_handler_test.exs index 841de83..2e97b8d 100644 --- a/test/bodhi/tg_webhook_handler_test.exs +++ b/test/bodhi/tg_update_handler_test.exs @@ -1,12 +1,12 @@ -defmodule Bodhi.TgWebhookHandlerTest do +defmodule Bodhi.TgUpdateHandlerTest do use Bodhi.ObanCase alias Telegex.Type.{Chat, Message, MessageEntity, Update, User} - alias Bodhi.TgWebhookHandler + alias Bodhi.TgUpdateHandler describe "handle_update/1" do test "Any TG update are handled correctly" do - assert :ok == TgWebhookHandler.on_update(%Update{update_id: Faker.random_bytes(100)}) + assert :ok == TgUpdateHandler.on_update(%Update{update_id: Faker.random_bytes(100)}) end @tag text: Faker.Lorem.sentence() @@ -88,7 +88,7 @@ defmodule Bodhi.TgWebhookHandlerTest do }} end) - assert :ok == Bodhi.TgWebhookHandler.on_update(update) + assert :ok == Bodhi.TgUpdateHandler.on_update(update) end @tag text: Faker.Lorem.sentence() @@ -106,14 +106,14 @@ defmodule Bodhi.TgWebhookHandlerTest do {:error, :service_unavailable} end) - assert :ok == Bodhi.TgWebhookHandler.on_update(update) + assert :ok == Bodhi.TgUpdateHandler.on_update(update) end end describe "send_message/3" do test "empty text returns early without calling Telegram", %{chat: chat} do assert {:ok, nil} == - TgWebhookHandler.send_message(chat.id, "") + TgUpdateHandler.send_message(chat.id, "") end test "Sends and saves message correctly", %{chat: chat, bot_user: bot_user} do @@ -140,7 +140,7 @@ defmodule Bodhi.TgWebhookHandlerTest do end) assert {:ok, %Bodhi.Chats.Message{} = message} = - TgWebhookHandler.send_message(chat_id, text) + TgUpdateHandler.send_message(chat_id, text) assert message.chat_id == chat_id assert message.text == text @@ -190,7 +190,7 @@ defmodule Bodhi.TgWebhookHandlerTest do } assert {:ok, _msg} = - TgWebhookHandler.send_message( + TgUpdateHandler.send_message( chat_id, text, metadata @@ -235,7 +235,7 @@ defmodule Bodhi.TgWebhookHandlerTest do end) assert {:error, _} = - TgWebhookHandler.send_message(chat_id, text) + TgUpdateHandler.send_message(chat_id, text) end end @@ -337,7 +337,7 @@ defmodule Bodhi.TgWebhookHandlerTest do end assert :ok == - TgWebhookHandler.on_update(update) + TgUpdateHandler.on_update(update) if db? do assert [received | other] = diff --git a/test/bodhi_web/controllers/telegram_webhook_controller_test.exs b/test/bodhi_web/controllers/telegram_webhook_controller_test.exs new file mode 100644 index 0000000..6fa85a3 --- /dev/null +++ b/test/bodhi_web/controllers/telegram_webhook_controller_test.exs @@ -0,0 +1,46 @@ +defmodule BodhiWeb.TelegramWebhookControllerTest do + use BodhiWeb.ConnCase + + @secret_header "x-telegram-bot-api-secret-token" + + setup do + previous = Application.get_env(:bodhi, Bodhi.TgHookHandler, []) + Application.put_env(:bodhi, Bodhi.TgHookHandler, secret_token: "s3cr3t") + + on_exit(fn -> + Application.put_env(:bodhi, Bodhi.TgHookHandler, previous) + end) + + :ok + end + + test "POST /api/telegram/webhook with a valid secret returns 200", %{ + conn: conn + } do + conn = + conn + |> put_req_header(@secret_header, "s3cr3t") + |> post("/api/telegram/webhook", %{"update_id" => 1}) + + assert json_response(conn, 200) == %{} + end + + test "POST /api/telegram/webhook with an invalid secret returns 401", %{ + conn: conn + } do + conn = + conn + |> put_req_header(@secret_header, "wrong") + |> post("/api/telegram/webhook", %{"update_id" => 1}) + + assert conn.status == 401 + end + + test "POST /api/telegram/webhook without a secret returns 401", %{ + conn: conn + } do + conn = post(conn, "/api/telegram/webhook", %{"update_id" => 1}) + + assert conn.status == 401 + end +end diff --git a/test/bodhi_web/plugs/telegram_webhook_auth_test.exs b/test/bodhi_web/plugs/telegram_webhook_auth_test.exs new file mode 100644 index 0000000..ebb7204 --- /dev/null +++ b/test/bodhi_web/plugs/telegram_webhook_auth_test.exs @@ -0,0 +1,56 @@ +defmodule BodhiWeb.Plugs.TelegramWebhookAuthTest do + use ExUnit.Case, async: false + + import Plug.Conn + import Plug.Test + + alias BodhiWeb.Plugs.TelegramWebhookAuth + + @secret_header "x-telegram-bot-api-secret-token" + + setup do + previous = Application.get_env(:bodhi, Bodhi.TgHookHandler, []) + + on_exit(fn -> + Application.put_env(:bodhi, Bodhi.TgHookHandler, previous) + end) + + :ok + end + + test "passes through when the secret token matches" do + Application.put_env(:bodhi, Bodhi.TgHookHandler, secret_token: "s3cr3t") + + conn = + :post + |> conn("/api/telegram/webhook", "{}") + |> put_req_header(@secret_header, "s3cr3t") + |> TelegramWebhookAuth.call(TelegramWebhookAuth.init([])) + + refute conn.halted + end + + test "halts with 401 when the secret token does not match" do + Application.put_env(:bodhi, Bodhi.TgHookHandler, secret_token: "s3cr3t") + + conn = + :post + |> conn("/api/telegram/webhook", "{}") + |> put_req_header(@secret_header, "wrong") + |> TelegramWebhookAuth.call(TelegramWebhookAuth.init([])) + + assert conn.halted + assert conn.status == 401 + end + + test "passes through when no token is configured and none is sent" do + Application.put_env(:bodhi, Bodhi.TgHookHandler, []) + + conn = + :post + |> conn("/api/telegram/webhook", "{}") + |> TelegramWebhookAuth.call(TelegramWebhookAuth.init([])) + + refute conn.halted + end +end