Skip to main content

src/telega_webapp.erl

-module(telega_webapp).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/telega_webapp.gleam").
-export([validate/2, is_fresh/3, validate_with_max_age/3, validate_third_party/3, parse/1, answer_web_app_query/3]).
-export_type([web_app_user/0, web_app_chat/0, web_app_init_data/0, web_app_error/0, environment/0, api_response/1]).

-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(
    " Telegram Mini Apps (Web Apps) support for Telega.\n"
    "\n"
    " A Mini App receives a signed `initData` string from the Telegram client.\n"
    " Your backend must verify that string before trusting any of the user data\n"
    " inside it. This module covers the full server-side flow:\n"
    "\n"
    " - [`validate`](#validate) / [`validate_with_max_age`](#validate_with_max_age)\n"
    "   — verify the `HMAC-SHA256` signature produced with your bot token (the\n"
    "   standard first-party check) and parse the payload into typed values.\n"
    " - [`validate_third_party`](#validate_third_party) — verify the `Ed25519`\n"
    "   `signature` field, for apps opened on behalf of *another* bot.\n"
    " - [`parse`](#parse) — decode `initData` into [`WebAppInitData`](#WebAppInitData)\n"
    "   without checking any signature (use only on already-trusted input).\n"
    " - [`answer_web_app_query`](#answer_web_app_query) — reply to an inline Mini\n"
    "   App query with a result.\n"
    "\n"
    " ## Validation\n"
    "\n"
    " The signing scheme (see the [official docs](https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app)):\n"
    " `secret_key = HMAC_SHA256(bot_token, \"WebAppData\")`, then the expected hash\n"
    " is `HMAC_SHA256(data_check_string, secret_key)` where `data_check_string`\n"
    " is every field except `hash`/`signature`, sorted by key and joined with\n"
    " newlines as `key=value`.\n"
    "\n"
    " ```gleam\n"
    " import telega_webapp\n"
    "\n"
    " // `init_data` is the raw query string from `Telegram.WebApp.initData`,\n"
    " // forwarded by your frontend (e.g. in an `Authorization` header).\n"
    " case telega_webapp.validate_with_max_age(token, init_data, 86_400) {\n"
    "   Ok(data) -> {\n"
    "     // Trusted. `data.user` is who opened the app.\n"
    "     todo\n"
    "   }\n"
    "   Error(_) -> todo  // reject the request\n"
    " }\n"
    " ```\n"
).

-type web_app_user() :: {web_app_user,
        integer(),
        binary(),
        gleam@option:option(binary()),
        gleam@option:option(binary()),
        gleam@option:option(binary()),
        gleam@option:option(boolean()),
        gleam@option:option(boolean()),
        gleam@option:option(boolean()),
        gleam@option:option(boolean()),
        gleam@option:option(binary())}.

-type web_app_chat() :: {web_app_chat,
        integer(),
        binary(),
        binary(),
        gleam@option:option(binary()),
        gleam@option:option(binary())}.

-type web_app_init_data() :: {web_app_init_data,
        gleam@option:option(binary()),
        gleam@option:option(web_app_user()),
        gleam@option:option(web_app_user()),
        gleam@option:option(web_app_chat()),
        gleam@option:option(binary()),
        gleam@option:option(binary()),
        gleam@option:option(binary()),
        gleam@option:option(integer()),
        integer(),
        binary(),
        gleam@option:option(binary())}.

-type web_app_error() :: malformed_init_data |
    {invalid_field, binary()} |
    missing_hash |
    missing_signature |
    signature_mismatch |
    outdated.

-type environment() :: production | test.

-type api_response(BEPL) :: {api_success, BEPL} |
    {api_failure, integer(), binary()}.

-file("src/telega_webapp.gleam", 344).
-spec string_field(list({binary(), binary()}), binary()) -> gleam@option:option(binary()).
string_field(Pairs, Key) ->
    _pipe = gleam@list:key_find(Pairs, Key),
    gleam@option:from_result(_pipe).

-file("src/telega_webapp.gleam", 358).
-spec parse_optional_int_field(list({binary(), binary()}), binary()) -> {ok,
        gleam@option:option(integer())} |
    {error, web_app_error()}.
parse_optional_int_field(Pairs, Key) ->
    case gleam@list:key_find(Pairs, Key) of
        {error, _} ->
            {ok, none};

        {ok, Raw} ->
            _pipe = gleam_stdlib:parse_int(Raw),
            _pipe@1 = gleam@result:replace_error(_pipe, {invalid_field, Key}),
            gleam@result:map(_pipe@1, fun(Field@0) -> {some, Field@0} end)
    end.

-file("src/telega_webapp.gleam", 442).
-spec web_app_chat_decoder() -> gleam@dynamic@decode:decoder(web_app_chat()).
web_app_chat_decoder() ->
    gleam@dynamic@decode:field(
        <<"id"/utf8>>,
        {decoder, fun gleam@dynamic@decode:decode_int/1},
        fun(Id) ->
            gleam@dynamic@decode:field(
                <<"type"/utf8>>,
                {decoder, fun gleam@dynamic@decode:decode_string/1},
                fun(Type_) ->
                    gleam@dynamic@decode:field(
                        <<"title"/utf8>>,
                        {decoder, fun gleam@dynamic@decode:decode_string/1},
                        fun(Title) ->
                            gleam@dynamic@decode:optional_field(
                                <<"username"/utf8>>,
                                none,
                                gleam@dynamic@decode:optional(
                                    {decoder,
                                        fun gleam@dynamic@decode:decode_string/1}
                                ),
                                fun(Username) ->
                                    gleam@dynamic@decode:optional_field(
                                        <<"photo_url"/utf8>>,
                                        none,
                                        gleam@dynamic@decode:optional(
                                            {decoder,
                                                fun gleam@dynamic@decode:decode_string/1}
                                        ),
                                        fun(Photo_url) ->
                                            gleam@dynamic@decode:success(
                                                {web_app_chat,
                                                    Id,
                                                    Type_,
                                                    Title,
                                                    Username,
                                                    Photo_url}
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/telega_webapp.gleam", 371).
-spec parse_json_field(
    list({binary(), binary()}),
    binary(),
    gleam@dynamic@decode:decoder(BEQN)
) -> {ok, gleam@option:option(BEQN)} | {error, web_app_error()}.
parse_json_field(Pairs, Key, Decoder) ->
    case gleam@list:key_find(Pairs, Key) of
        {error, _} ->
            {ok, none};

        {ok, Raw} ->
            _pipe = gleam@json:parse(Raw, Decoder),
            _pipe@1 = gleam@result:replace_error(_pipe, {invalid_field, Key}),
            gleam@result:map(_pipe@1, fun(Field@0) -> {some, Field@0} end)
    end.

-file("src/telega_webapp.gleam", 385).
-spec web_app_user_decoder() -> gleam@dynamic@decode:decoder(web_app_user()).
web_app_user_decoder() ->
    gleam@dynamic@decode:field(
        <<"id"/utf8>>,
        {decoder, fun gleam@dynamic@decode:decode_int/1},
        fun(Id) ->
            gleam@dynamic@decode:field(
                <<"first_name"/utf8>>,
                {decoder, fun gleam@dynamic@decode:decode_string/1},
                fun(First_name) ->
                    gleam@dynamic@decode:optional_field(
                        <<"last_name"/utf8>>,
                        none,
                        gleam@dynamic@decode:optional(
                            {decoder, fun gleam@dynamic@decode:decode_string/1}
                        ),
                        fun(Last_name) ->
                            gleam@dynamic@decode:optional_field(
                                <<"username"/utf8>>,
                                none,
                                gleam@dynamic@decode:optional(
                                    {decoder,
                                        fun gleam@dynamic@decode:decode_string/1}
                                ),
                                fun(Username) ->
                                    gleam@dynamic@decode:optional_field(
                                        <<"language_code"/utf8>>,
                                        none,
                                        gleam@dynamic@decode:optional(
                                            {decoder,
                                                fun gleam@dynamic@decode:decode_string/1}
                                        ),
                                        fun(Language_code) ->
                                            gleam@dynamic@decode:optional_field(
                                                <<"is_bot"/utf8>>,
                                                none,
                                                gleam@dynamic@decode:optional(
                                                    {decoder,
                                                        fun gleam@dynamic@decode:decode_bool/1}
                                                ),
                                                fun(Is_bot) ->
                                                    gleam@dynamic@decode:optional_field(
                                                        <<"is_premium"/utf8>>,
                                                        none,
                                                        gleam@dynamic@decode:optional(
                                                            {decoder,
                                                                fun gleam@dynamic@decode:decode_bool/1}
                                                        ),
                                                        fun(Is_premium) ->
                                                            gleam@dynamic@decode:optional_field(
                                                                <<"added_to_attachment_menu"/utf8>>,
                                                                none,
                                                                gleam@dynamic@decode:optional(
                                                                    {decoder,
                                                                        fun gleam@dynamic@decode:decode_bool/1}
                                                                ),
                                                                fun(
                                                                    Added_to_attachment_menu
                                                                ) ->
                                                                    gleam@dynamic@decode:optional_field(
                                                                        <<"allows_write_to_pm"/utf8>>,
                                                                        none,
                                                                        gleam@dynamic@decode:optional(
                                                                            {decoder,
                                                                                fun gleam@dynamic@decode:decode_bool/1}
                                                                        ),
                                                                        fun(
                                                                            Allows_write_to_pm
                                                                        ) ->
                                                                            gleam@dynamic@decode:optional_field(
                                                                                <<"photo_url"/utf8>>,
                                                                                none,
                                                                                gleam@dynamic@decode:optional(
                                                                                    {decoder,
                                                                                        fun gleam@dynamic@decode:decode_string/1}
                                                                                ),
                                                                                fun(
                                                                                    Photo_url
                                                                                ) ->
                                                                                    gleam@dynamic@decode:success(
                                                                                        {web_app_user,
                                                                                            Id,
                                                                                            First_name,
                                                                                            Last_name,
                                                                                            Username,
                                                                                            Language_code,
                                                                                            Is_bot,
                                                                                            Is_premium,
                                                                                            Added_to_attachment_menu,
                                                                                            Allows_write_to_pm,
                                                                                            Photo_url}
                                                                                    )
                                                                                end
                                                                            )
                                                                        end
                                                                    )
                                                                end
                                                            )
                                                        end
                                                    )
                                                end
                                            )
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/telega_webapp.gleam", 348).
-spec parse_int_field(list({binary(), binary()}), binary()) -> {ok, integer()} |
    {error, web_app_error()}.
parse_int_field(Pairs, Key) ->
    gleam@result:'try'(
        begin
            _pipe = gleam@list:key_find(Pairs, Key),
            gleam@result:replace_error(_pipe, {invalid_field, Key})
        end,
        fun(Raw) -> _pipe@1 = gleam_stdlib:parse_int(Raw),
            gleam@result:replace_error(_pipe@1, {invalid_field, Key}) end
    ).

-file("src/telega_webapp.gleam", 313).
-spec parse_pairs(list({binary(), binary()})) -> {ok, web_app_init_data()} |
    {error, web_app_error()}.
parse_pairs(Pairs) ->
    gleam@result:'try'(
        parse_int_field(Pairs, <<"auth_date"/utf8>>),
        fun(Auth_date) ->
            gleam@result:'try'(
                parse_json_field(Pairs, <<"user"/utf8>>, web_app_user_decoder()),
                fun(User) ->
                    gleam@result:'try'(
                        parse_json_field(
                            Pairs,
                            <<"receiver"/utf8>>,
                            web_app_user_decoder()
                        ),
                        fun(Receiver) ->
                            gleam@result:'try'(
                                parse_json_field(
                                    Pairs,
                                    <<"chat"/utf8>>,
                                    web_app_chat_decoder()
                                ),
                                fun(Chat) ->
                                    gleam@result:'try'(
                                        parse_optional_int_field(
                                            Pairs,
                                            <<"can_send_after"/utf8>>
                                        ),
                                        fun(Can_send_after) ->
                                            {ok,
                                                {web_app_init_data,
                                                    string_field(
                                                        Pairs,
                                                        <<"query_id"/utf8>>
                                                    ),
                                                    User,
                                                    Receiver,
                                                    Chat,
                                                    string_field(
                                                        Pairs,
                                                        <<"chat_type"/utf8>>
                                                    ),
                                                    string_field(
                                                        Pairs,
                                                        <<"chat_instance"/utf8>>
                                                    ),
                                                    string_field(
                                                        Pairs,
                                                        <<"start_param"/utf8>>
                                                    ),
                                                    Can_send_after,
                                                    Auth_date,
                                                    begin
                                                        _pipe = gleam@list:key_find(
                                                            Pairs,
                                                            <<"hash"/utf8>>
                                                        ),
                                                        gleam@result:unwrap(
                                                            _pipe,
                                                            <<""/utf8>>
                                                        )
                                                    end,
                                                    string_field(
                                                        Pairs,
                                                        <<"signature"/utf8>>
                                                    )}}
                                        end
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/telega_webapp.gleam", 296).
?DOC(
    " All fields except `hash`/`signature`, sorted by key, joined as\n"
    " `key=value` with newlines.\n"
).
-spec data_check_string(list({binary(), binary()})) -> binary().
data_check_string(Pairs) ->
    _pipe = Pairs,
    _pipe@1 = gleam@list:filter(
        _pipe,
        fun(Pair) ->
            (erlang:element(1, Pair) /= <<"hash"/utf8>>) andalso (erlang:element(
                1,
                Pair
            )
            /= <<"signature"/utf8>>)
        end
    ),
    _pipe@2 = gleam@list:sort(
        _pipe@1,
        fun(A, B) ->
            gleam@string:compare(erlang:element(1, A), erlang:element(1, B))
        end
    ),
    _pipe@3 = gleam@list:map(
        _pipe@2,
        fun(Pair@1) ->
            <<<<(erlang:element(1, Pair@1))/binary, "="/utf8>>/binary,
                (erlang:element(2, Pair@1))/binary>>
        end
    ),
    gleam@string:join(_pipe@3, <<"\n"/utf8>>).

-file("src/telega_webapp.gleam", 277).
?DOC(
    " Telegram's `initData` HMAC: the secret key is itself an HMAC of the token\n"
    " keyed by the literal `\"WebAppData\"`, then the data-check string is signed\n"
    " with that secret. Returned lowercase-hex to match the wire `hash`.\n"
).
-spec sign(binary(), binary()) -> binary().
sign(Token, Data_check_string) ->
    Secret_key = gleam_crypto_ffi:hmac(
        gleam_stdlib:identity(Token),
        sha256,
        gleam_stdlib:identity(<<"WebAppData"/utf8>>)
    ),
    _pipe = gleam_crypto_ffi:hmac(
        gleam_stdlib:identity(Data_check_string),
        sha256,
        Secret_key
    ),
    _pipe@1 = gleam_stdlib:base16_encode(_pipe),
    string:lowercase(_pipe@1).

-file("src/telega_webapp.gleam", 304).
-spec raw_pairs(binary()) -> {ok, list({binary(), binary()})} |
    {error, web_app_error()}.
raw_pairs(Init_data) ->
    _pipe = gleam_stdlib:parse_query(Init_data),
    gleam@result:replace_error(_pipe, malformed_init_data).

-file("src/telega_webapp.gleam", 147).
?DOC(
    " Validate `init_data` against your bot `token` using the first-party\n"
    " `HMAC-SHA256` scheme and return the typed payload on success.\n"
    "\n"
    " This does **not** check `auth_date` freshness — use\n"
    " [`validate_with_max_age`](#validate_with_max_age) to also reject stale data,\n"
    " which you almost always want in production.\n"
).
-spec validate(binary(), binary()) -> {ok, web_app_init_data()} |
    {error, web_app_error()}.
validate(Token, Init_data) ->
    gleam@result:'try'(
        raw_pairs(Init_data),
        fun(Pairs) ->
            gleam@result:'try'(
                begin
                    _pipe = gleam@list:key_find(Pairs, <<"hash"/utf8>>),
                    gleam@result:replace_error(_pipe, missing_hash)
                end,
                fun(Hash) ->
                    Expected = sign(Token, data_check_string(Pairs)),
                    case gleam@crypto:secure_compare(
                        gleam_stdlib:identity(Expected),
                        gleam_stdlib:identity(Hash)
                    ) of
                        true ->
                            parse_pairs(Pairs);

                        false ->
                            {error, signature_mismatch}
                    end
                end
            )
        end
    ).

-file("src/telega_webapp.gleam", 547).
-spec now_seconds() -> integer().
now_seconds() ->
    os:system_time(erlang:binary_to_atom(<<"second"/utf8>>)).

-file("src/telega_webapp.gleam", 238).
?DOC(
    " Whether `auth_date` is within `max_age_seconds` of `now_unix` (both in Unix\n"
    " seconds). Exposed for callers that supply their own clock.\n"
).
-spec is_fresh(web_app_init_data(), integer(), integer()) -> boolean().
is_fresh(Data, Max_age_seconds, Now_unix) ->
    (erlang:element(10, Data) + Max_age_seconds) >= Now_unix.

-file("src/telega_webapp.gleam", 172).
?DOC(
    " Like [`validate`](#validate), but also rejects data whose `auth_date` is\n"
    " older than `max_age_seconds` relative to the current system time.\n"
    "\n"
    " A typical `max_age` is one day (`86_400`).\n"
).
-spec validate_with_max_age(binary(), binary(), integer()) -> {ok,
        web_app_init_data()} |
    {error, web_app_error()}.
validate_with_max_age(Token, Init_data, Max_age_seconds) ->
    gleam@result:'try'(
        validate(Token, Init_data),
        fun(Data) -> case is_fresh(Data, Max_age_seconds, now_seconds()) of
                true ->
                    {ok, Data};

                false ->
                    {error, outdated}
            end end
    ).

-file("src/telega_webapp.gleam", 535).
?DOC(
    " Telegram's public keys for verifying third-party `signature`s, as published\n"
    " in the [docs](https://core.telegram.org/bots/webapps#validating-data-for-third-party-use).\n"
).
-spec public_key(environment()) -> bitstring().
public_key(Environment) ->
    Hex = case Environment of
        production ->
            <<"e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d"/utf8>>;

        test ->
            <<"40055058a4ee38156a06562e52eece92a771bcb8346deeb3d33aff7f55cea4be"/utf8>>
    end,
    _pipe = gleam_stdlib:base16_decode(Hex),
    gleam@result:unwrap(_pipe, <<>>).

-file("src/telega_webapp.gleam", 505).
?DOC(
    " Verify an `Ed25519` signature via the Erlang `crypto` module. The `eddsa`\n"
    " algorithm expects the public key as a `[PublicKey, ed25519]` term, which we\n"
    " build with `gleam/dynamic` rather than an FFI shim.\n"
).
-spec verify_ed25519(bitstring(), bitstring(), bitstring()) -> boolean().
verify_ed25519(Message, Signature, Public_key) ->
    Key = gleam_stdlib:identity(
        [gleam_stdlib:identity(Public_key),
            gleam_erlang_ffi:identity(erlang:binary_to_atom(<<"ed25519"/utf8>>))]
    ),
    crypto:verify(
        erlang:binary_to_atom(<<"eddsa"/utf8>>),
        erlang:binary_to_atom(<<"none"/utf8>>),
        Message,
        Signature,
        Key
    ).

-file("src/telega_webapp.gleam", 190).
?DOC(
    " Validate `init_data` issued for a *third-party* bot using the `Ed25519`\n"
    " `signature` field. `bot_id` is the numeric id of the bot the Mini App was\n"
    " opened for (the part before `:` in its token).\n"
    "\n"
    " Use this when your service receives Mini App data for bots you don't hold\n"
    " the token of; otherwise prefer [`validate`](#validate).\n"
).
-spec validate_third_party(integer(), binary(), environment()) -> {ok,
        web_app_init_data()} |
    {error, web_app_error()}.
validate_third_party(Bot_id, Init_data, Environment) ->
    gleam@result:'try'(
        raw_pairs(Init_data),
        fun(Pairs) ->
            gleam@result:'try'(
                begin
                    _pipe = gleam@list:key_find(Pairs, <<"signature"/utf8>>),
                    gleam@result:replace_error(_pipe, missing_signature)
                end,
                fun(Signature) ->
                    gleam@result:'try'(
                        begin
                            _pipe@1 = gleam@bit_array:base64_url_decode(
                                Signature
                            ),
                            gleam@result:replace_error(
                                _pipe@1,
                                {invalid_field, <<"signature"/utf8>>}
                            )
                        end,
                        fun(Signature_bytes) ->
                            gleam@bool:guard(
                                erlang:byte_size(Signature_bytes) /= 64,
                                {error, signature_mismatch},
                                fun() ->
                                    Message = <<<<(erlang:integer_to_binary(
                                                Bot_id
                                            ))/binary,
                                            ":WebAppData\n"/utf8>>/binary,
                                        (data_check_string(Pairs))/binary>>,
                                    case verify_ed25519(
                                        gleam_stdlib:identity(Message),
                                        Signature_bytes,
                                        public_key(Environment)
                                    ) of
                                        true ->
                                            parse_pairs(Pairs);

                                        false ->
                                            {error, signature_mismatch}
                                    end
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/telega_webapp.gleam", 229).
?DOC(
    " Decode `init_data` into typed values **without** verifying any signature.\n"
    "\n"
    " Only use on input you have already validated (or trust for another reason);\n"
    " for request handling use [`validate`](#validate) instead.\n"
).
-spec parse(binary()) -> {ok, web_app_init_data()} | {error, web_app_error()}.
parse(Init_data) ->
    gleam@result:'try'(
        raw_pairs(Init_data),
        fun(Pairs) -> parse_pairs(Pairs) end
    ).

-file("src/telega_webapp.gleam", 483).
-spec api_response_decoder(gleam@dynamic@decode:decoder(BERB)) -> gleam@dynamic@decode:decoder(api_response(BERB)).
api_response_decoder(Result_decoder) ->
    gleam@dynamic@decode:field(
        <<"ok"/utf8>>,
        {decoder, fun gleam@dynamic@decode:decode_bool/1},
        fun(Ok) -> case Ok of
                true ->
                    gleam@dynamic@decode:field(
                        <<"result"/utf8>>,
                        Result_decoder,
                        fun(Result) ->
                            gleam@dynamic@decode:success({api_success, Result})
                        end
                    );

                false ->
                    gleam@dynamic@decode:optional_field(
                        <<"error_code"/utf8>>,
                        0,
                        {decoder, fun gleam@dynamic@decode:decode_int/1},
                        fun(Error_code) ->
                            gleam@dynamic@decode:optional_field(
                                <<"description"/utf8>>,
                                <<""/utf8>>,
                                {decoder,
                                    fun gleam@dynamic@decode:decode_string/1},
                                fun(Description) ->
                                    gleam@dynamic@decode:success(
                                        {api_failure, Error_code, Description}
                                    )
                                end
                            )
                        end
                    )
            end end
    ).

-file("src/telega_webapp.gleam", 466).
-spec map_response(
    {ok, gleam@http@response:response(binary())} |
        {error, telega@error:telega_error()},
    gleam@dynamic@decode:decoder(BEQX)
) -> {ok, BEQX} | {error, telega@error:telega_error()}.
map_response(Response, Result_decoder) ->
    gleam@result:'try'(
        Response,
        fun(Response@1) ->
            _pipe = gleam@json:parse(
                erlang:element(4, Response@1),
                api_response_decoder(Result_decoder)
            ),
            _pipe@1 = gleam@result:map_error(
                _pipe,
                fun(Field@0) -> {json_decode_error, Field@0} end
            ),
            gleam@result:'try'(_pipe@1, fun(Parsed) -> case Parsed of
                        {api_success, Result} ->
                            {ok, Result};

                        {api_failure, Error_code, Description} ->
                            {error,
                                {telegram_api_error, Error_code, Description}}
                    end end)
        end
    ).

-file("src/telega_webapp.gleam", 252).
?DOC(
    " Reply to an inline Mini App query via\n"
    " [answerWebAppQuery](https://core.telegram.org/bots/api#answerwebappquery).\n"
    "\n"
    " `web_app_query_id` comes from the `web_app_data`/`WebAppData` query sent by\n"
    " the app; build `result` with `telega/inline_mode` or the raw\n"
    " `telega/model/types` constructors.\n"
).
-spec answer_web_app_query(
    telega@client:telegram_client(),
    binary(),
    telega@model@types:inline_query_result()
) -> {ok, telega@model@types:sent_web_app_message()} |
    {error, telega@error:telega_error()}.
answer_web_app_query(Client, Web_app_query_id, Result) ->
    Body = gleam@json:object(
        [{<<"web_app_query_id"/utf8>>, gleam@json:string(Web_app_query_id)},
            {<<"result"/utf8>>,
                telega@model@encoder:encode_inline_query_result(Result)}]
    ),
    _pipe = telega@client:new_post_request(
        Client,
        <<"answerWebAppQuery"/utf8>>,
        gleam@json:to_string(Body)
    ),
    _pipe@1 = telega@client:fetch(_pipe, Client),
    map_response(_pipe@1, telega@model@decoder:sent_web_app_message_decoder()).