Current section

Files

Jump to
telega src telega@webhook_reply.erl
Raw

src/telega@webhook_reply.erl

-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>> ->
<<Method_field/binary, "}"/utf8>>;
Params ->
case gleam_stdlib:string_starts_with(Params, <<"{"/utf8>>) of
true ->
<<<<Method_field/binary, ","/utf8>>/binary,
(gleam@string:drop_start(Params, 1))/binary>>;
false ->
<<Method_field/binary, "}"/utf8>>
end
end.