From 25b586211dccae9f25b0ff4904b54c0325794f03 Mon Sep 17 00:00:00 2001 From: Anton Shvein aka T0ha Date: Fri, 1 May 2026 17:20:15 +0400 Subject: [PATCH 1/5] feat: add webhook mode for production, keep polling for dev/test Split TgWebhookHandler into three modules: - TgUpdateHandler: shared business logic - TgPollingHandler: thin polling wrapper for dev - TgHookHandler: thin webhook wrapper for production Handler selection is driven by :tg_mode config (:polling/:webhook/:disabled). Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 4 +- config/config.exs | 1 + config/dev.exs | 2 +- config/runtime.exs | 11 +++ config/test.exs | 1 + docs/SUMMARIZATION.md | 2 +- lib/bodhi/application.ex | 12 ++- lib/bodhi/periodic_messages.ex | 2 +- lib/bodhi/tg_hook_handler.ex | 31 +++++++ lib/bodhi/tg_polling_handler.ex | 19 ++++ ...ebhook_handler.ex => tg_update_handler.ex} | 88 ++++++++++++------- ...er_test.exs => tg_update_handler_test.exs} | 20 ++--- 12 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 lib/bodhi/tg_hook_handler.ex create mode 100644 lib/bodhi/tg_polling_handler.ex rename lib/bodhi/{tg_webhook_handler.ex => tg_update_handler.ex} (75%) rename test/bodhi/{tg_webhook_handler_test.exs => tg_update_handler_test.exs} (95%) diff --git a/AGENTS.md b/AGENTS.md index a548f71..d69b782 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_webhook_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:** 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/dev.exs b/config/dev.exs index 117f2fe..a6609dc 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -19,7 +19,7 @@ config :bodhi, Bodhi.Repo, config :bodhi, BodhiWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: 4000], + http: [ip: {127, 0, 0, 1}, port: 4003], check_origin: false, code_reloader: true, debug_errors: true, diff --git a/config/runtime.exs b/config/runtime.exs index 1f320e9..81c0417 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -32,6 +32,17 @@ config :posthog, api_key: "#{System.get_env("POSTHOG_KEY")}" if config_env() == :prod do + config :bodhi, :tg_mode, :webhook + + webhook_url = + System.get_env("TG_WEBHOOK_URL") || + raise "environment variable TG_WEBHOOK_URL is missing" + + config :bodhi, Bodhi.TgHookHandler, + webhook_url: webhook_url, + secret_token: System.get_env("TG_WEBHOOK_SECRET"), + server_port: String.to_integer(System.get_env("TG_WEBHOOK_PORT") || "4001") + 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/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/tg_hook_handler.ex b/lib/bodhi/tg_hook_handler.ex new file mode 100644 index 0000000..3dd3069 --- /dev/null +++ b/lib/bodhi/tg_hook_handler.ex @@ -0,0 +1,31 @@ +defmodule Bodhi.TgHookHandler do + @moduledoc """ + Telegram webhook handler for production. + Delegates update processing to `Bodhi.TgUpdateHandler`. + """ + use Telegex.Hook.GenHandler + + @impl true + @spec on_boot :: Telegex.Hook.Config.t() + def on_boot do + env_config = + Application.get_env(:bodhi, __MODULE__, []) + + {:ok, true} = Telegex.delete_webhook() + + {:ok, true} = + Telegex.set_webhook(env_config[:webhook_url], + secret_token: env_config[:secret_token] + ) + + %Telegex.Hook.Config{ + server_port: env_config[:server_port] + } + 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_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/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] = From c1f5b14014c7b28fb56dca59d858e34dad9fff14 Mon Sep 17 00:00:00 2001 From: Anton Shvein aka T0ha Date: Fri, 1 May 2026 17:51:06 +0400 Subject: [PATCH 2/5] fix: integrate webhook into Phoenix routes, address PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Telegex.Hook.GenHandler with a GenServer that registers the webhook on boot + a Phoenix controller that receives updates - Require TG_WEBHOOK_SECRET in production (was silently optional) - Use `with` in webhook setup to handle API errors gracefully - Fix stale file path in AGENTS.md - Revert accidental dev port change (4003 → 4000) - Remove separate webhook port config (TG_WEBHOOK_PORT) Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 2 +- config/dev.exs | 2 +- config/runtime.exs | 7 ++- lib/bodhi/tg_hook_handler.ex | 55 ++++++++++++------- .../telegram_webhook_controller.ex | 53 ++++++++++++++++++ lib/bodhi_web/router.ex | 9 +-- 6 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 lib/bodhi_web/controllers/telegram_webhook_controller.ex diff --git a/AGENTS.md b/AGENTS.md index d69b782..3da6547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ 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.TgUpdateHandler`** (`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:** diff --git a/config/dev.exs b/config/dev.exs index a6609dc..117f2fe 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -19,7 +19,7 @@ config :bodhi, Bodhi.Repo, config :bodhi, BodhiWeb.Endpoint, # Binding to loopback ipv4 address prevents access from other machines. # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {127, 0, 0, 1}, port: 4003], + http: [ip: {127, 0, 0, 1}, port: 4000], check_origin: false, code_reloader: true, debug_errors: true, diff --git a/config/runtime.exs b/config/runtime.exs index 81c0417..361d5ff 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -38,10 +38,13 @@ if config_env() == :prod do System.get_env("TG_WEBHOOK_URL") || raise "environment variable TG_WEBHOOK_URL is missing" + secret_token = + System.get_env("TG_WEBHOOK_SECRET") || + raise "environment variable TG_WEBHOOK_SECRET is missing" + config :bodhi, Bodhi.TgHookHandler, webhook_url: webhook_url, - secret_token: System.get_env("TG_WEBHOOK_SECRET"), - server_port: String.to_integer(System.get_env("TG_WEBHOOK_PORT") || "4001") + secret_token: secret_token database_url = System.get_env("DATABASE_URL") || diff --git a/lib/bodhi/tg_hook_handler.ex b/lib/bodhi/tg_hook_handler.ex index 3dd3069..70bc5a0 100644 --- a/lib/bodhi/tg_hook_handler.ex +++ b/lib/bodhi/tg_hook_handler.ex @@ -1,31 +1,48 @@ defmodule Bodhi.TgHookHandler do @moduledoc """ - Telegram webhook handler for production. - Delegates update processing to `Bodhi.TgUpdateHandler`. + Registers the Telegram webhook on application start. + + Reads webhook URL and secret token from application + config, deletes any existing webhook, then sets a new + one pointing to the Phoenix endpoint. The actual HTTP + handling is done by + `BodhiWeb.TelegramWebhookController`. """ - use Telegex.Hook.GenHandler + use GenServer + + require Logger + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end @impl true - @spec on_boot :: Telegex.Hook.Config.t() - def on_boot do - env_config = + @spec init(keyword()) :: + {:ok, map()} | {:stop, {:webhook_setup_failed, term()}} + def init(_opts) do + config = Application.get_env(:bodhi, __MODULE__, []) - {:ok, true} = Telegex.delete_webhook() - - {:ok, true} = - Telegex.set_webhook(env_config[:webhook_url], - secret_token: env_config[:secret_token] + with {:ok, true} <- Telegex.delete_webhook(), + {:ok, true} <- + Telegex.set_webhook(config[:webhook_url], + secret_token: config[:secret_token] + ) do + Logger.info( + "Telegram webhook registered: " <> + "#{config[:webhook_url]}" ) - %Telegex.Hook.Config{ - server_port: env_config[:server_port] - } - end + {:ok, %{}} + else + error -> + Logger.error( + "Failed to configure Telegram webhook: " <> + "#{inspect(error)}" + ) - @impl true - @spec on_update(Telegex.Type.Update.t()) :: :ok - def on_update(update) do - Bodhi.TgUpdateHandler.on_update(update) + {:stop, {:webhook_setup_failed, error}} + end end end 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..b05ec2c --- /dev/null +++ b/lib/bodhi_web/controllers/telegram_webhook_controller.ex @@ -0,0 +1,53 @@ +defmodule BodhiWeb.TelegramWebhookController do + @moduledoc """ + Receives Telegram webhook updates via Phoenix routes. + + Validates the secret token from the + `X-Telegram-Bot-Api-Secret-Token` header, parses the + update, and delegates to `Bodhi.TgUpdateHandler`. + """ + use BodhiWeb, :controller + + require Logger + + @secret_header "x-telegram-bot-api-secret-token" + + @spec webhook(Plug.Conn.t(), map()) :: Plug.Conn.t() + def webhook(conn, params) do + config = + Application.get_env(:bodhi, Bodhi.TgHookHandler, []) + + if authorized?(conn, config[:secret_token]) do + update = + Telegex.Helper.typedmap( + params, + Telegex.Type.Update + ) + + Task.start(fn -> + Bodhi.TgUpdateHandler.on_update(update) + end) + else + Logger.warning( + "Unauthorized webhook request from " <> + "`#{remote_ip(conn)}`" + ) + end + + json(conn, %{}) + 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..f3c351b 100644 --- a/lib/bodhi_web/router.ex +++ b/lib/bodhi_web/router.ex @@ -56,10 +56,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 + + post "/webhook", TelegramWebhookController, :webhook + end # Enables LiveDashboard only for development # From faec63d29d52377101041acb8ab9c58e2c032f45 Mon Sep 17 00:00:00 2001 From: Anton Shvein aka T0ha Date: Mon, 4 May 2026 17:35:58 +0400 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20PR=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20auth=20plug,=20sync=20controller,=20derived=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract secret-token check into BodhiWeb.Plugs.TelegramWebhookAuth and pipe the webhook scope through it instead of branching in the action - Drop Task.start from controller; the request process already handles the update synchronously - Derive webhook URL from BodhiWeb.Endpoint.url() + route path; remove TG_WEBHOOK_URL env var (TG_WEBHOOK_SECRET still required in prod) Co-Authored-By: Claude Opus 4.7 (1M context) --- config/runtime.exs | 8 +-- lib/bodhi/tg_hook_handler.ex | 17 +++--- .../telegram_webhook_controller.ex | 46 +++------------ lib/bodhi_web/plug/telegram_webhook_auth.ex | 56 +++++++++++++++++++ lib/bodhi_web/router.ex | 6 +- 5 files changed, 78 insertions(+), 55 deletions(-) create mode 100644 lib/bodhi_web/plug/telegram_webhook_auth.ex diff --git a/config/runtime.exs b/config/runtime.exs index 361d5ff..535d037 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -34,17 +34,11 @@ config :posthog, if config_env() == :prod do config :bodhi, :tg_mode, :webhook - webhook_url = - System.get_env("TG_WEBHOOK_URL") || - raise "environment variable TG_WEBHOOK_URL is missing" - secret_token = System.get_env("TG_WEBHOOK_SECRET") || raise "environment variable TG_WEBHOOK_SECRET is missing" - config :bodhi, Bodhi.TgHookHandler, - webhook_url: webhook_url, - secret_token: secret_token + config :bodhi, Bodhi.TgHookHandler, secret_token: secret_token database_url = System.get_env("DATABASE_URL") || diff --git a/lib/bodhi/tg_hook_handler.ex b/lib/bodhi/tg_hook_handler.ex index 70bc5a0..3bfcdeb 100644 --- a/lib/bodhi/tg_hook_handler.ex +++ b/lib/bodhi/tg_hook_handler.ex @@ -2,9 +2,9 @@ defmodule Bodhi.TgHookHandler do @moduledoc """ Registers the Telegram webhook on application start. - Reads webhook URL and secret token from application - config, deletes any existing webhook, then sets a new - one pointing to the Phoenix endpoint. The actual HTTP + 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`. """ @@ -12,6 +12,8 @@ defmodule Bodhi.TgHookHandler do 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__) @@ -24,15 +26,14 @@ defmodule Bodhi.TgHookHandler do config = Application.get_env(:bodhi, __MODULE__, []) + webhook_url = BodhiWeb.Endpoint.url() <> @webhook_path + with {:ok, true} <- Telegex.delete_webhook(), {:ok, true} <- - Telegex.set_webhook(config[:webhook_url], + Telegex.set_webhook(webhook_url, secret_token: config[:secret_token] ) do - Logger.info( - "Telegram webhook registered: " <> - "#{config[:webhook_url]}" - ) + Logger.info("Telegram webhook registered: #{webhook_url}") {:ok, %{}} else diff --git a/lib/bodhi_web/controllers/telegram_webhook_controller.ex b/lib/bodhi_web/controllers/telegram_webhook_controller.ex index b05ec2c..c98108a 100644 --- a/lib/bodhi_web/controllers/telegram_webhook_controller.ex +++ b/lib/bodhi_web/controllers/telegram_webhook_controller.ex @@ -2,52 +2,20 @@ defmodule BodhiWeb.TelegramWebhookController do @moduledoc """ Receives Telegram webhook updates via Phoenix routes. - Validates the secret token from the - `X-Telegram-Bot-Api-Secret-Token` header, parses the - update, and delegates to `Bodhi.TgUpdateHandler`. + Authentication is performed by + `BodhiWeb.Plugs.TelegramWebhookAuth` in the router. + This action parses the update and delegates to + `Bodhi.TgUpdateHandler`. """ use BodhiWeb, :controller - require Logger - - @secret_header "x-telegram-bot-api-secret-token" - @spec webhook(Plug.Conn.t(), map()) :: Plug.Conn.t() def webhook(conn, params) do - config = - Application.get_env(:bodhi, Bodhi.TgHookHandler, []) + update = + Telegex.Helper.typedmap(params, Telegex.Type.Update) - if authorized?(conn, config[:secret_token]) do - update = - Telegex.Helper.typedmap( - params, - Telegex.Type.Update - ) - - Task.start(fn -> - Bodhi.TgUpdateHandler.on_update(update) - end) - else - Logger.warning( - "Unauthorized webhook request from " <> - "`#{remote_ip(conn)}`" - ) - end + Bodhi.TgUpdateHandler.on_update(update) json(conn, %{}) 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/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 f3c351b..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] @@ -57,7 +61,7 @@ defmodule BodhiWeb.Router do end scope "/api/telegram", BodhiWeb do - pipe_through :api + pipe_through [:api, :telegram_webhook] post "/webhook", TelegramWebhookController, :webhook end From 5e1fdea3d6bec684d0a525234cde48a2903f5746 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 07:21:46 +0000 Subject: [PATCH 4/5] test: add coverage for webhook handler, polling handler, and auth plug Extend Bodhi.Behaviours.TelegramClient with delete_webhook/0 and set_webhook/2 so Bodhi.TgHookHandler goes through the same Bodhi.Telegram indirection as every other Telegram call, making webhook registration mockable with Bodhi.TelegramMock. Add tests for the previously-untested TgHookHandler, TgPollingHandler, TelegramWebhookAuth plug, and TelegramWebhookController, and document the polling/webhook handler split in AGENTS.md and the required TG_WEBHOOK_SECRET env var in README.md. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 43 ++++++++++++++ README.md | 14 +++++ lib/bodhi/behaviours/telegram_client.ex | 12 ++++ lib/bodhi/telegram.ex | 16 ++++++ lib/bodhi/telegram/telegex_adapter.ex | 10 ++++ lib/bodhi/tg_hook_handler.ex | 4 +- test/bodhi/tg_hook_handler_test.exs | 44 +++++++++++++++ test/bodhi/tg_polling_handler_test.exs | 20 +++++++ .../telegram_webhook_controller_test.exs | 46 +++++++++++++++ .../plugs/telegram_webhook_auth_test.exs | 56 +++++++++++++++++++ 10 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 test/bodhi/tg_hook_handler_test.exs create mode 100644 test/bodhi/tg_polling_handler_test.exs create mode 100644 test/bodhi_web/controllers/telegram_webhook_controller_test.exs create mode 100644 test/bodhi_web/plugs/telegram_webhook_auth_test.exs diff --git a/AGENTS.md b/AGENTS.md index 3da6547..e99f2c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ba5d907..4c7892f 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. + ## Features ### Daily Dialog Summarization 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/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 index 3bfcdeb..42c3928 100644 --- a/lib/bodhi/tg_hook_handler.ex +++ b/lib/bodhi/tg_hook_handler.ex @@ -28,9 +28,9 @@ defmodule Bodhi.TgHookHandler do webhook_url = BodhiWeb.Endpoint.url() <> @webhook_path - with {:ok, true} <- Telegex.delete_webhook(), + with {:ok, true} <- Bodhi.Telegram.delete_webhook(), {:ok, true} <- - Telegex.set_webhook(webhook_url, + Bodhi.Telegram.set_webhook(webhook_url, secret_token: config[:secret_token] ) do Logger.info("Telegram webhook registered: #{webhook_url}") diff --git a/test/bodhi/tg_hook_handler_test.exs b/test/bodhi/tg_hook_handler_test.exs new file mode 100644 index 0000000..a552dd9 --- /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}} = + 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}} = + 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_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 From b0f2f27e36e8e1aa41aebe16b882e10d7ec14a22 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 07:33:58 +0000 Subject: [PATCH 5/5] fix: match wrapped error shape from start_supervised in hook handler tests start_supervised/1 starts children under a Supervisor, so a permanent child's init failure surfaces as {:error, {reason, child_spec}}, not {:error, reason} directly. CI caught the mismatch since Postgres (and therefore mix test) isn't available in this sandbox. Co-Authored-By: Claude Sonnet 5 --- test/bodhi/tg_hook_handler_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/bodhi/tg_hook_handler_test.exs b/test/bodhi/tg_hook_handler_test.exs index a552dd9..8ec6946 100644 --- a/test/bodhi/tg_hook_handler_test.exs +++ b/test/bodhi/tg_hook_handler_test.exs @@ -26,7 +26,7 @@ defmodule Bodhi.TgHookHandlerTest do {:error, %Telegex.Error{error_code: 500, description: "boom"}} end) - assert {:error, {:webhook_setup_failed, _reason}} = + assert {:error, {{:webhook_setup_failed, _reason}, _child_spec}} = start_supervised(TgHookHandler) end @@ -37,7 +37,7 @@ defmodule Bodhi.TgHookHandlerTest do {:error, %Telegex.Error{error_code: 500, description: "boom"}} end) - assert {:error, {:webhook_setup_failed, _reason}} = + assert {:error, {{:webhook_setup_failed, _reason}, _child_spec}} = start_supervised(TgHookHandler) end end