-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()).