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