-module(telega@webhook_reply). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/telega/webhook_reply.gleam"). -export([transformer/1, without_claim/1, to_response_body/1]). -export_type([webhook_reply/0, envelope_message/0, atomics_ref/0]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. ?MODULEDOC( " Webhook reply optimization: answer an update by embedding a bot API call\n" " into the webhook HTTP response body instead of making a separate HTTP\n" " request to Telegram.\n" "\n" " Telegram allows the webhook HTTP response to carry one API call as JSON\n" " (`{\"method\": \"sendMessage\", ...}`) — it is executed as if it were a normal\n" " request, saving one HTTP round-trip and not counting against the request\n" " quota. This module implements the claim protocol between a handler's API\n" " call and the webhook HTTP process that is still holding the connection.\n" "\n" " > ⚠️ Telegram does **not** return the result of an embedded call. A claimed\n" " > call resolves to a synthetic stub: `True` for boolean methods, and for\n" " > `sendMessage` a fake `Message` with `message_id: -1` and `date: 0`. If\n" " > your handler needs the real `message_id`, do not use webhook reply for\n" " > that call.\n" "\n" " This is opt-in: adapters expose it as `handle_bot_with_reply`, built on\n" " `telega.handle_update_webhook`. Regular `handle_bot` behavior is unchanged.\n" "\n" " ## Usage\n" "\n" " ```gleam\n" " import telega_wisp\n" "\n" " fn handle_request(bot, req) {\n" " use <- telega_wisp.handle_bot_with_reply(bot, req, timeout: 5000)\n" " // other routes...\n" " }\n" " ```\n" "\n" " `telega_mist.handle_bot_with_reply` works the same way. For a custom\n" " adapter, call `telega.handle_update_webhook(telega:, update:, timeout:)`\n" " and map the result yourself:\n" "\n" " ```gleam\n" " case telega.handle_update_webhook(telega:, update:, timeout: 5000) {\n" " // Answer 200 with this JSON body, Content-Type: application/json.\n" " telega.JsonResponse(body:) -> todo\n" " // Answer an empty 200.\n" " telega.EmptyResponse -> todo\n" " }\n" " ```\n" "\n" " With `handle_bot_with_reply`, a bot answering a callback button makes\n" " **zero** outgoing HTTP calls for that update.\n" "\n" " ## Eligible methods\n" "\n" " Only the first eligible **POST** call per update is claimed:\n" "\n" " - `answerCallbackQuery`, `deleteMessage`, `setMessageReaction`,\n" " `sendChatAction` — boolean methods, the stub is honest;\n" " - `sendMessage` — with the documented fake `Message` above (the chat id is\n" " copied from your request when numeric, `-1` otherwise).\n" "\n" " Everything else — GET requests included — always goes over HTTP. If a\n" " handler needs the real `message_id` (to edit or reply to the message\n" " later), that call must go over regular HTTP — do not use webhook reply\n" " for it.\n" "\n" " ## Latency semantics\n" "\n" " Unlike `handle_bot`, the request process **waits** — up to `timeout` ms —\n" " for the handler to either claim a call or finish. Telegram serializes\n" " updates per webhook connection slot, so pick a `timeout` well below\n" " Telegram's own webhook timeout; 5000 ms is a sensible default. When the\n" " timeout fires the adapter answers an empty `200 OK`, the handler keeps\n" " running in the background, and all of its API calls go over regular HTTP.\n" "\n" " ## Claim protocol\n" "\n" " - `telega.handle_update_webhook` creates a per-update `Envelope` and\n" " dispatches the update, then waits on the envelope and on handler\n" " completion, whichever comes first.\n" " - The chat instance wraps that update's API client with `transformer`,\n" " which serializes the *first* eligible call into a `Claim` and waits for\n" " a grant for at most 100 ms.\n" " - First-wins, race-free: only an HTTP process that is still waiting grants\n" " a claim. If it already answered (or timed out), the grant wait expires\n" " and the call falls back to a regular HTTP request. At most one claim is\n" " attempted per update, so the 100 ms window is paid at most once, and\n" " only when the HTTP process is already gone.\n" " - On a grant, `handle_update_webhook` splices `\"method\"` into the request\n" " params and returns `JsonResponse`.\n" "\n" " The claimed call resolves inside your handler *immediately* on grant —\n" " the handler does not wait for Telegram to execute it.\n" "\n" " ## Testing\n" "\n" " Use a routed mock and assert on the response body — see\n" " `test/webhook_reply_test.gleam` for full examples:\n" "\n" " ```gleam\n" " let assert telega.JsonResponse(body:) =\n" " telega.handle_update_webhook(telega: bot, update: raw, timeout: 5000)\n" " string.contains(body, \"\\\"method\\\":\\\"answerCallbackQuery\\\"\")\n" " ```\n" ). -type webhook_reply() :: {webhook_reply, binary(), binary()}. -type envelope_message() :: {claim, webhook_reply(), gleam@erlang@process:subject(boolean())}. -type atomics_ref() :: any(). -file("src/telega/webhook_reply.gleam", 248). ?DOC( " Minimal `Message` JSON that satisfies `message_decoder()`. The fake fields\n" " are intentional and documented: `message_id: -1`, `date: 0`. The chat id is\n" " copied from the request params when it is numeric, `-1` otherwise.\n" ). -spec stub_message_json(binary()) -> binary(). stub_message_json(Params_json) -> Chat_id = begin _pipe = gleam@json:parse( Params_json, gleam@dynamic@decode:at( [<<"chat_id"/utf8>>], {decoder, fun gleam@dynamic@decode:decode_int/1} ) ), gleam@result:unwrap(_pipe, -1) end, <<<<"{\"message_id\":-1,\"date\":0,\"chat\":{\"id\":"/utf8, (erlang:integer_to_binary(Chat_id))/binary>>/binary, ",\"type\":\"private\"}}"/utf8>>. -file("src/telega/webhook_reply.gleam", 236). ?DOC( " The response a claimed call resolves to locally. Telegram never returns\n" " the result of an embedded call, so boolean methods get an honest `true`\n" " and `sendMessage` gets the documented fake `Message`.\n" ). -spec synthetic_response(binary(), binary()) -> gleam@http@response:response(binary()). synthetic_response(Method, Params_json) -> Result = case Method of <<"sendMessage"/utf8>> -> stub_message_json(Params_json); _ -> <<"true"/utf8>> end, _pipe = gleam@http@response:new(200), gleam@http@response:set_body( _pipe, <<<<"{\"ok\":true,\"result\":"/utf8, Result/binary>>/binary, "}"/utf8>> ). -file("src/telega/webhook_reply.gleam", 190). -spec claim_suppressed() -> boolean(). claim_suppressed() -> _pipe = erlang:get(<<"__telega_webhook_reply_suppress"/utf8>>), _pipe@1 = gleam@dynamic@decode:run( _pipe, {decoder, fun gleam@dynamic@decode:decode_string/1} ), gleam@result:is_ok(_pipe@1). -file("src/telega/webhook_reply.gleam", 208). ?DOC( " Whether a method may be answered in the webhook response body.\n" " Only methods whose synthetic result is honest (`True` for boolean methods)\n" " or explicitly documented as fake (`sendMessage`) are eligible.\n" ). -spec is_claimable(binary()) -> boolean(). is_claimable(Method) -> case Method of <<"answerCallbackQuery"/utf8>> -> true; <<"deleteMessage"/utf8>> -> true; <<"setMessageReaction"/utf8>> -> true; <<"sendChatAction"/utf8>> -> true; <<"sendMessage"/utf8>> -> true; _ -> false end. -file("src/telega/webhook_reply.gleam", 137). ?DOC( " Build the API-call transformer that implements the handler side of the\n" " claim protocol for one update. Installed automatically by the chat\n" " instance when the update was dispatched with an envelope — not meant to be\n" " added manually.\n" ). -spec transformer(gleam@erlang@process:subject(envelope_message())) -> fun((telega@client:telegram_api_request(), fun((telega@client:telegram_api_request()) -> {ok, gleam@http@response:response(binary())} | {error, telega@error:telega_error()})) -> {ok, gleam@http@response:response(binary())} | {error, telega@error:telega_error()}). transformer(Envelope) -> Claimed = atomics:new(1, []), fun(Request, Next) -> Method = telega@client:request_method(Request), case {is_claimable(Method) andalso not claim_suppressed(), telega@client:request_body(Request)} of {true, {some, Params_json}} -> case atomics:add_get(Claimed, 1, 1) of 1 -> Granted = gleam@erlang@process:new_subject(), gleam@erlang@process:send( Envelope, {claim, {webhook_reply, Method, Params_json}, Granted} ), case gleam@erlang@process:'receive'(Granted, 100) of {ok, true} -> {ok, synthetic_response(Method, Params_json)}; _ -> Next(Request) end; _ -> Next(Request) end; {_, _} -> Next(Request) end end. -file("src/telega/webhook_reply.gleam", 183). ?DOC( " Run `work` with webhook-reply claiming disabled for the calling process.\n" " Use around API calls whose real result you need — a claimed call would\n" " resolve to a synthetic stub instead (see the module doc).\n" ). -spec without_claim(fun(() -> AHDW)) -> AHDW. without_claim(Work) -> _ = erlang:put(<<"__telega_webhook_reply_suppress"/utf8>>, <<"1"/utf8>>), Result = Work(), _ = erlang:erase(<<"__telega_webhook_reply_suppress"/utf8>>), Result. -file("src/telega/webhook_reply.gleam", 221). ?DOC( " Render a granted claim as the webhook HTTP response body:\n" " the request params with `\"method\"` spliced in front.\n" ). -spec to_response_body(webhook_reply()) -> binary(). to_response_body(Reply) -> Method_field = <<<<"{\"method\":\""/utf8, (erlang:element(2, Reply))/binary>>/binary, "\""/utf8>>, case erlang:element(3, Reply) of <<"{}"/utf8>> -> <>; Params -> case gleam_stdlib:string_starts_with(Params, <<"{"/utf8>>) of true -> <<<>/binary, (gleam@string:drop_start(Params, 1))/binary>>; false -> <> end end.