Current section
Files
Jump to
Current section
Files
src/telega@dialog@render.erl
-module(telega@dialog@render).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/telega/dialog/render.gleam").
-export([is_not_modified/1, is_message_not_found/1, is_cant_be_edited/1, callback_data/4, pack_callback_data/4, build_markup/3, render_window/6, remove_keyboard/3, alert/2, toast/2, auto_answer/1, answer_quietly/2, describe_error/1]).
-export_type([render_error/0, message_kind/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(
" The edit-or-send engine behind dialogs.\n"
"\n"
" Keeps one live dialog message per chat: sends it the first time, edits it\n"
" on every subsequent render. Media windows are supported by the strategy\n"
" matrix below — the Bot API cannot turn a text message into a media one\n"
" (or back), so a kind change recreates the message:\n"
"\n"
" | was \\ becomes | text | media |\n"
" |-------------------|----------------------------|----------------------------|\n"
" | — (no message) | `sendMessage` | `sendPhoto`/`sendVideo`/… |\n"
" | text | `editMessageText` | delete (best effort) + send |\n"
" | media (any kind) | delete (best effort) + send | `editMessageMedia` |\n"
"\n"
" `deleteMessage` is best effort (messages older than 48 hours cannot be\n"
" deleted): its failure is swallowed and the fresh message is sent anyway.\n"
"\n"
" Also hosts the `alert`/`toast` helpers and the Bot API error classifiers\n"
" (`is_not_modified` & co) — the latter are useful outside dialogs too.\n"
).
-type render_error() :: {api_error, telega@error:telega_error()} |
{invalid_button, binary(), binary()}.
-type message_kind() :: text_message | media_message.
-file("src/telega/dialog/render.gleam", 64).
?DOC(
" The edit was a no-op: new content equals the current one. Safe to treat\n"
" as success.\n"
).
-spec is_not_modified(telega@error:telega_error()) -> boolean().
is_not_modified(Error) ->
telega@error:is_message_not_modified(Error).
-file("src/telega/dialog/render.gleam", 69).
?DOC(" The message to edit no longer exists (deleted by the user or too old).\n").
-spec is_message_not_found(telega@error:telega_error()) -> boolean().
is_message_not_found(Error) ->
telega@error:is_message_not_found(Error).
-file("src/telega/dialog/render.gleam", 74).
?DOC(" The message exists but cannot be edited (e.g. not sent by the bot).\n").
-spec is_cant_be_edited(telega@error:telega_error()) -> boolean().
is_cant_be_edited(Error) ->
telega@error:is_message_cant_be_edited(Error).
-file("src/telega/dialog/render.gleam", 172).
-spec delete_best_effort(
telega@bot:context(any(), any(), any()),
integer(),
integer()
) -> nil.
delete_best_effort(Ctx, Chat_id, Message_id) ->
_ = telega@api:delete_message(
erlang:element(5, erlang:element(4, Ctx)),
{delete_message_parameters, {int, Chat_id}, Message_id}
),
nil.
-file("src/telega/dialog/render.gleam", 152).
?DOC(
" Apply the shared edit-fallback matrix: \"not modified\" is success, a gone\n"
" message falls back to a fresh send, anything else is an error.\n"
).
-spec or_send_fallback(
{ok, integer()} | {error, telega@error:telega_error()},
integer(),
message_kind(),
fun(() -> {ok, {integer(), message_kind()}} | {error, render_error()})
) -> {ok, {integer(), message_kind()}} | {error, render_error()}.
or_send_fallback(Edited, Current_id, Kind, Send) ->
case Edited of
{ok, Message_id} ->
{ok, {Message_id, Kind}};
{error, Api_error} ->
case {is_not_modified(Api_error),
is_message_not_found(Api_error) orelse is_cant_be_edited(
Api_error
)} of
{true, _} ->
{ok, {Current_id, Kind}};
{_, true} ->
Send();
{_, _} ->
{error, {api_error, Api_error}}
end
end.
-file("src/telega/dialog/render.gleam", 450).
-spec caption_for(binary()) -> gleam@option:option(binary()).
caption_for(Text) ->
case Text of
<<""/utf8>> ->
none;
_ ->
{some, Text}
end.
-file("src/telega/dialog/render.gleam", 457).
-spec spoiler_option(boolean()) -> gleam@option:option(boolean()).
spoiler_option(Has_spoiler) ->
case Has_spoiler of
true ->
{some, true};
false ->
none
end.
-file("src/telega/dialog/render.gleam", 389).
-spec input_media(
telega@dialog@types:dialog_media(),
gleam@option:option(binary()),
binary()
) -> telega@model@types:input_media().
input_media(Media, Caption, Parse_mode) ->
Parse_mode@1 = gleam@option:map(Caption, fun(_) -> Parse_mode end),
case Media of
{photo_media, Media@1, Has_spoiler} ->
{input_media_photo_input_media,
{input_media_photo,
<<"photo"/utf8>>,
Media@1,
Caption,
Parse_mode@1,
none,
none,
spoiler_option(Has_spoiler)}};
{video_media, Media@2, Has_spoiler@1} ->
{input_media_video_input_media,
{input_media_video,
<<"video"/utf8>>,
Media@2,
none,
none,
none,
Caption,
Parse_mode@1,
none,
none,
none,
none,
none,
none,
spoiler_option(Has_spoiler@1)}};
{animation_media, Media@3} ->
{input_media_animation_input_media,
{input_media_animation,
<<"animation"/utf8>>,
Media@3,
none,
Caption,
Parse_mode@1,
none,
none,
none,
none,
none,
none}};
{document_media, Media@4} ->
{input_media_document_input_media,
{input_media_document,
<<"document"/utf8>>,
Media@4,
none,
Caption,
Parse_mode@1,
none,
none}}
end.
-file("src/telega/dialog/render.gleam", 366).
-spec edit_media_window(
telega@bot:context(any(), any(), any()),
integer(),
integer(),
telega@dialog@types:dialog_media(),
binary(),
binary(),
gleam@option:option(telega@model@types:inline_keyboard_markup())
) -> {ok, integer()} | {error, telega@error:telega_error()}.
edit_media_window(Ctx, Chat_id, Message_id, Media, Text, Parse_mode, Markup) ->
_pipe = telega@api:edit_message_media(
erlang:element(5, erlang:element(4, Ctx)),
{edit_message_media_parameters,
none,
{some, {int, Chat_id}},
{some, Message_id},
none,
input_media(Media, caption_for(Text), Parse_mode),
Markup}
),
gleam@result:map(_pipe, fun(Message) -> erlang:element(2, Message) end).
-file("src/telega/dialog/render.gleam", 223).
-spec edit_window(
telega@bot:context(any(), any(), any()),
integer(),
integer(),
binary(),
binary(),
gleam@option:option(telega@model@types:inline_keyboard_markup())
) -> {ok, integer()} | {error, telega@error:telega_error()}.
edit_window(Ctx, Chat_id, Message_id, Text, Parse_mode, Markup) ->
_pipe = telega@api:edit_message_text(
erlang:element(5, erlang:element(4, Ctx)),
{edit_message_text_parameters,
{some, {int, Chat_id}},
{some, Message_id},
none,
Text,
{some, Parse_mode},
none,
none,
Markup}
),
gleam@result:map(_pipe, fun(Message) -> erlang:element(2, Message) end).
-file("src/telega/dialog/render.gleam", 249).
-spec send_media_window(
telega@bot:context(any(), any(), any()),
integer(),
telega@dialog@types:dialog_media(),
binary(),
binary(),
gleam@option:option(telega@model@types:inline_keyboard_markup())
) -> {ok, integer()} | {error, render_error()}.
send_media_window(Ctx, Chat_id, Media, Text, Parse_mode, Markup) ->
telega@webhook_reply:without_claim(
fun() ->
Chat_id@1 = {int, Chat_id},
Caption = caption_for(Text),
Parse_mode@1 = gleam@option:map(Caption, fun(_) -> Parse_mode end),
Reply_markup = gleam@option:map(
Markup,
fun(Field@0) -> {send_message_reply_inline_keyboard_markup_parameters, Field@0} end
),
_pipe = case Media of
{photo_media, Media@1, Has_spoiler} ->
telega@api:send_photo(
erlang:element(5, erlang:element(4, Ctx)),
{send_photo_parameters,
Chat_id@1,
none,
none,
{string_v, Media@1},
Caption,
Parse_mode@1,
none,
none,
spoiler_option(Has_spoiler),
none,
none,
none,
none,
none,
Reply_markup}
);
{video_media, Media@2, Has_spoiler@1} ->
telega@api:send_video(
erlang:element(5, erlang:element(4, Ctx)),
{send_video_parameters,
Chat_id@1,
none,
none,
{string_v, Media@2},
none,
none,
none,
none,
none,
none,
Caption,
Parse_mode@1,
none,
none,
spoiler_option(Has_spoiler@1),
none,
none,
none,
none,
none,
none,
Reply_markup}
);
{animation_media, Media@3} ->
telega@api:send_animation(
erlang:element(5, erlang:element(4, Ctx)),
{send_animation_parameters,
Chat_id@1,
none,
none,
{string_v, Media@3},
none,
none,
none,
none,
Caption,
Parse_mode@1,
none,
none,
none,
none,
none,
none,
none,
none,
Reply_markup}
);
{document_media, Media@4} ->
telega@api:send_document(
erlang:element(5, erlang:element(4, Ctx)),
{send_document_parameters,
Chat_id@1,
none,
none,
{string_v, Media@4},
none,
Caption,
Parse_mode@1,
none,
none,
none,
none,
none,
none,
none,
Reply_markup}
)
end,
_pipe@1 = gleam@result:map(
_pipe,
fun(Message) -> erlang:element(2, Message) end
),
gleam@result:map_error(
_pipe@1,
fun(Field@0) -> {api_error, Field@0} end
)
end
).
-file("src/telega/dialog/render.gleam", 188).
-spec send_window(
telega@bot:context(any(), any(), any()),
integer(),
binary(),
binary(),
gleam@option:option(telega@model@types:inline_keyboard_markup())
) -> {ok, integer()} | {error, render_error()}.
send_window(Ctx, Chat_id, Text, Parse_mode, Markup) ->
telega@webhook_reply:without_claim(
fun() ->
_pipe = telega@api:send_message(
erlang:element(5, erlang:element(4, Ctx)),
{send_message_parameters,
none,
{int, Chat_id},
none,
Text,
{some, Parse_mode},
none,
none,
none,
none,
none,
none,
none,
gleam@option:map(
Markup,
fun(Field@0) -> {send_message_reply_inline_keyboard_markup_parameters, Field@0} end
)}
),
_pipe@1 = gleam@result:map(
_pipe,
fun(Message) -> erlang:element(2, Message) end
),
gleam@result:map_error(
_pipe@1,
fun(Field@0) -> {api_error, Field@0} end
)
end
).
-file("src/telega/dialog/render.gleam", 548).
?DOC(
" The exact callback-data string for an action — the single place the\n"
" `dlg:` scheme is constructed. Build-time validation measures budgets\n"
" through this same function, so packing and validation can never diverge.\n"
).
-spec callback_data(binary(), binary(), binary(), gleam@option:option(binary())) -> binary().
callback_data(Dialog_id, Window_id, Action_id, Arg) ->
Data = <<<<<<<<<<"dlg:"/utf8, Dialog_id/binary>>/binary, ":"/utf8>>/binary,
Window_id/binary>>/binary,
":"/utf8>>/binary,
Action_id/binary>>,
case Arg of
{some, Arg@1} ->
<<<<Data/binary, ":"/utf8>>/binary, Arg@1/binary>>;
none ->
Data
end.
-file("src/telega/dialog/render.gleam", 567).
?DOC(
" Build callback data for an action, checking the reserved `:` in the\n"
" action id and Telegram's 64-byte limit. The `w:` action-id prefix is the\n"
" widget namespace (`w:<widget_id>:<cmd>`) and is the only place `:` is\n"
" allowed — the engine routes such presses to the widget's `on_event`. The\n"
" bare id `\"w\"` is rejected too: with a `:`-carrying arg it would be\n"
" indistinguishable from a widget event on parse.\n"
).
-spec pack_callback_data(
binary(),
binary(),
binary(),
gleam@option:option(binary())
) -> {ok, binary()} | {error, render_error()}.
pack_callback_data(Dialog_id, Window_id, Action_id, Arg) ->
gleam@bool:guard(
not (gleam_stdlib:string_starts_with(Action_id, <<"w:"/utf8>>) orelse not gleam_stdlib:contains_string(
Action_id,
<<":"/utf8>>
)),
{error,
{invalid_button,
Action_id,
<<"action id must not contain ':' (reserved for the w: widget namespace)"/utf8>>}},
fun() ->
gleam@bool:guard(
Action_id =:= <<"w"/utf8>>,
{error,
{invalid_button,
Action_id,
<<"action id 'w' is reserved for the widget namespace"/utf8>>}},
fun() ->
Data = callback_data(Dialog_id, Window_id, Action_id, Arg),
Bytes = erlang:byte_size(Data),
gleam@bool:guard(
Bytes > 64,
{error,
{invalid_button,
Action_id,
<<<<<<"callback data is "/utf8,
(erlang:integer_to_binary(Bytes))/binary>>/binary,
" bytes, max is "/utf8>>/binary,
(erlang:integer_to_binary(64))/binary>>}},
fun() -> {ok, Data} end
)
end
)
end
).
-file("src/telega/dialog/render.gleam", 509).
-spec build_button(binary(), binary(), telega@dialog@types:dialog_button()) -> {ok,
telega@model@types:inline_keyboard_button()} |
{error, render_error()}.
build_button(Dialog_id, Window_id, Button) ->
case Button of
{action_button, Text, Action_id} ->
gleam@result:'try'(
pack_callback_data(Dialog_id, Window_id, Action_id, none),
fun(Data) ->
{ok, telega@keyboard:inline_raw_callback_button(Text, Data)}
end
);
{action_arg_button, Text@1, Action_id@1, Arg} ->
gleam@result:'try'(
pack_callback_data(
Dialog_id,
Window_id,
Action_id@1,
{some, Arg}
),
fun(Data@1) ->
{ok,
telega@keyboard:inline_raw_callback_button(
Text@1,
Data@1
)}
end
);
{url_button, Text@2, Url} ->
{ok, telega@keyboard:inline_url_button(Text@2, Url)};
{web_app_button, Text@3, Url@1} ->
{ok, telega@keyboard:inline_web_app_button(Text@3, Url@1)};
{noop_button, Text@4} ->
{ok, telega@keyboard:inline_copy_text_button(Text@4, Text@4)}
end.
-file("src/telega/dialog/render.gleam", 489).
?DOC(
" Build the inline keyboard for a window, generating callback data by the\n"
" `dlg:<dialog>:<window>:<action>[:<arg>]` scheme and validating the 64-byte\n"
" limit and reserved characters per button.\n"
).
-spec build_markup(
binary(),
binary(),
list(list(telega@dialog@types:dialog_button()))
) -> {ok, gleam@option:option(telega@model@types:inline_keyboard_markup())} |
{error, render_error()}.
build_markup(Dialog_id, Window_id, Buttons) ->
case Buttons of
[] ->
{ok, none};
_ ->
gleam@result:'try'(
gleam@list:try_map(
Buttons,
fun(Row) ->
gleam@list:try_map(
Row,
fun(Button) ->
build_button(Dialog_id, Window_id, Button)
end
)
end
),
fun(Rows) -> {ok, {some, {inline_keyboard_markup, Rows}}} end
)
end.
-file("src/telega/dialog/render.gleam", 95).
?DOC(
" Render a window into the live dialog message following the edit-or-send\n"
" strategy matrix (see the module doc): send when there is no message yet,\n"
" edit when the kind is unchanged, delete-and-resend when it changed. Edits\n"
" fall back to a fresh send when the old message is gone. Returns the id\n"
" and kind of the live message.\n"
).
-spec render_window(
telega@bot:context(any(), any(), any()),
integer(),
gleam@option:option({integer(), message_kind()}),
binary(),
binary(),
telega@dialog@types:rendered_window()
) -> {ok, {integer(), message_kind()}} | {error, render_error()}.
render_window(Ctx, Chat_id, Message, Dialog_id, Window_id, Window) ->
gleam@result:'try'(
build_markup(Dialog_id, Window_id, erlang:element(3, Window)),
fun(Markup) ->
{Text, Parse_mode} = telega@format:render(erlang:element(2, Window)),
Parse_mode@1 = telega@format:parse_mode_to_string(Parse_mode),
Send_text = fun() ->
_pipe = send_window(Ctx, Chat_id, Text, Parse_mode@1, Markup),
gleam@result:map(_pipe, fun(Id) -> {Id, text_message} end)
end,
Send_media = fun(Media) ->
_pipe@1 = send_media_window(
Ctx,
Chat_id,
Media,
Text,
Parse_mode@1,
Markup
),
gleam@result:map(
_pipe@1,
fun(Id@1) -> {Id@1, media_message} end
)
end,
case {Message, erlang:element(4, Window)} of
{none, none} ->
Send_text();
{none, {some, Media@1}} ->
Send_media(Media@1);
{{some, {Message_id, text_message}}, none} ->
_pipe@2 = edit_window(
Ctx,
Chat_id,
Message_id,
Text,
Parse_mode@1,
Markup
),
or_send_fallback(
_pipe@2,
Message_id,
text_message,
Send_text
);
{{some, {Message_id@1, media_message}}, {some, Media@2}} ->
_pipe@3 = edit_media_window(
Ctx,
Chat_id,
Message_id@1,
Media@2,
Text,
Parse_mode@1,
Markup
),
or_send_fallback(
_pipe@3,
Message_id@1,
media_message,
fun() -> Send_media(Media@2) end
);
{{some, {Message_id@2, text_message}}, {some, Media@3}} ->
delete_best_effort(Ctx, Chat_id, Message_id@2),
Send_media(Media@3);
{{some, {Message_id@3, media_message}}, none} ->
delete_best_effort(Ctx, Chat_id, Message_id@3),
Send_text()
end
end
).
-file("src/telega/dialog/render.gleam", 466).
?DOC(
" Remove the inline keyboard from the live dialog message (used on `Done`).\n"
" Best effort: failures are reported but callers usually ignore them.\n"
).
-spec remove_keyboard(
telega@bot:context(any(), any(), any()),
integer(),
integer()
) -> {ok, nil} | {error, telega@error:telega_error()}.
remove_keyboard(Ctx, Chat_id, Message_id) ->
_pipe = telega@api:edit_message_reply_markup(
erlang:element(5, erlang:element(4, Ctx)),
{edit_message_reply_markup_parameters,
none,
{some, {int, Chat_id}},
{some, Message_id},
none,
none}
),
gleam@result:replace(_pipe, nil).
-file("src/telega/dialog/render.gleam", 699).
-spec mark_answered(binary()) -> nil.
mark_answered(Query_id) ->
_ = erlang:put(<<"__telega_dialog_answered"/utf8>>, Query_id),
nil.
-file("src/telega/dialog/render.gleam", 656).
-spec do_answer(
telega@bot:context(any(), any(), any()),
binary(),
gleam@option:option(binary()),
boolean()
) -> {ok, nil} | {error, telega@error:telega_error()}.
do_answer(Ctx, Query_id, Text, Show_alert) ->
_pipe = telega@api:answer_callback_query(
erlang:element(5, erlang:element(4, Ctx)),
{answer_callback_query_parameters,
Query_id,
Text,
{some, Show_alert},
none,
none}
),
gleam@result:replace(_pipe, nil).
-file("src/telega/dialog/render.gleam", 641).
-spec answer_with(
telega@bot:context(any(), any(), any()),
gleam@option:option(binary()),
boolean()
) -> {ok, nil} | {error, telega@error:telega_error()}.
answer_with(Ctx, Text, Show_alert) ->
case erlang:element(3, Ctx) of
{callback_query_update, _, _, Query, _} ->
gleam@result:'try'(
do_answer(Ctx, erlang:element(2, Query), Text, Show_alert),
fun(_) ->
mark_answered(erlang:element(2, Query)),
{ok, nil}
end
);
_ ->
{ok, nil}
end.
-file("src/telega/dialog/render.gleam", 607).
?DOC(
" Show a modal alert to the user who pressed the button. Call from\n"
" `on_action` before returning; the engine then skips its automatic\n"
" `answer_callback_query` for this event.\n"
).
-spec alert(telega@bot:context(any(), any(), any()), binary()) -> {ok, nil} |
{error, telega@error:telega_error()}.
alert(Ctx, Text) ->
answer_with(Ctx, {some, Text}, true).
-file("src/telega/dialog/render.gleam", 616).
?DOC(
" Show a toast notification at the top of the chat. Same contract as\n"
" `alert`, but without the modal dialog.\n"
).
-spec toast(telega@bot:context(any(), any(), any()), binary()) -> {ok, nil} |
{error, telega@error:telega_error()}.
toast(Ctx, Text) ->
answer_with(Ctx, {some, Text}, false).
-file("src/telega/dialog/render.gleam", 704).
-spec take_answered() -> gleam@option:option(binary()).
take_answered() ->
_pipe = erlang:erase(<<"__telega_dialog_answered"/utf8>>),
_pipe@1 = gleam@dynamic@decode:run(
_pipe,
{decoder, fun gleam@dynamic@decode:decode_string/1}
),
gleam@option:from_result(_pipe@1).
-file("src/telega/dialog/render.gleam", 627).
?DOC(false).
-spec auto_answer(telega@bot:context(any(), any(), any())) -> nil.
auto_answer(Ctx) ->
case erlang:element(3, Ctx) of
{callback_query_update, _, _, Query, _} ->
case take_answered() =:= {some, erlang:element(2, Query)} of
true ->
nil;
false ->
_ = do_answer(Ctx, erlang:element(2, Query), none, false),
nil
end;
_ ->
nil
end.
-file("src/telega/dialog/render.gleam", 678).
?DOC(false).
-spec answer_quietly(
telega@bot:context(any(), any(), any()),
gleam@option:option(binary())
) -> nil.
answer_quietly(Ctx, Text) ->
case erlang:element(3, Ctx) of
{callback_query_update, _, _, Query, _} ->
_ = do_answer(Ctx, erlang:element(2, Query), Text, false),
nil;
_ ->
nil
end.
-file("src/telega/dialog/render.gleam", 718).
?DOC(false).
-spec describe_error(render_error()) -> binary().
describe_error(Error) ->
case Error of
{api_error, Api_error} ->
telega@error:to_string(Api_error);
{invalid_button, Action_id, Reason} ->
<<<<<<"invalid button '"/utf8, Action_id/binary>>/binary,
"': "/utf8>>/binary,
Reason/binary>>
end.