Current section

Files

Jump to
telega src telega@roles.erl
Raw

src/telega@roles.erl

-module(telega@roles).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/telega/roles.gleam").
-export([new_cache/1, is_admin/4, is_owner/4, ensure_admin/4, ensure_owner/4, require_admin/2, require_owner/2]).
-export_type([ets_table/0, role_cache/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(
" Role-based access control: gate handlers on a user's chat role.\n"
"\n"
" Telegram exposes a user's role in a chat through `getChatMember`. This\n"
" module wraps that call with a small TTL cache (one API round-trip is too\n"
" slow to repeat on every message) and exposes it three ways:\n"
"\n"
" - **Booleans** — [`is_admin`](#is_admin) / [`is_owner`](#is_owner) for ad-hoc\n"
" checks inside a handler.\n"
" - **`use` guards** — [`ensure_admin`](#ensure_admin) / [`ensure_owner`](#ensure_owner)\n"
" wrap a handler body and run an `on_denied` branch otherwise.\n"
" - **Router middleware** — [`require_admin`](#require_admin) /\n"
" [`require_owner`](#require_owner) gate every route of a (sub-)router.\n"
"\n"
" \"Admin\" means administrator *or* owner; \"owner\" means the chat creator only.\n"
"\n"
" ## Caching\n"
"\n"
" [`new_cache`](#new_cache) returns a cache backed by an ETS table owned by the\n"
" calling process — create it once at bot setup, not inside a handler. Entries\n"
" expire after `ttl_ms`; pass `ttl_ms: 0` to disable caching and always hit the\n"
" API. The cache is keyed by `{chat_id}:{user_id}`, so a role change (promote /\n"
" demote) is picked up after at most `ttl_ms`.\n"
"\n"
" ```gleam\n"
" import telega/roles\n"
" import telega/router\n"
"\n"
" // Cache roles for 60s.\n"
" let cache = roles.new_cache(ttl_ms: 60_000)\n"
"\n"
" // Admin-only /ban command:\n"
" router.new(\"admin\")\n"
" |> router.on_command(\"ban\", fn(ctx, _cmd) {\n"
" use ctx <- roles.ensure_admin(ctx, cache, on_denied: fn(ctx) {\n"
" reply.with_text(ctx, \"Admins only.\")\n"
" })\n"
" // ... ban logic, only reached for admins ...\n"
" Ok(ctx)\n"
" })\n"
" ```\n"
"\n"
" On an API error the check fails closed (access denied) and the result is not\n"
" cached, so the next update retries.\n"
).
-type ets_table() :: any().
-opaque role_cache() :: {role_cache, ets_table(), integer()}.
-file("src/telega/roles.gleam", 78).
?DOC(
" Create a role cache. Entries expire after `ttl_ms` milliseconds; `ttl_ms: 0`\n"
" disables caching (every check hits the API).\n"
"\n"
" The ETS table is owned by the calling process — create the cache from a\n"
" long-lived process (bot setup), not from a handler.\n"
).
-spec new_cache(integer()) -> role_cache().
new_cache(Ttl_ms) ->
Table = ets:new(
erlang:binary_to_atom(<<"telega_role_cache"/utf8>>),
[erlang:binary_to_atom(<<"set"/utf8>>),
erlang:binary_to_atom(<<"public"/utf8>>)]
),
{role_cache, Table, Ttl_ms}.
-file("src/telega/roles.gleam", 267).
-spec cache_put(role_cache(), binary(), binary()) -> nil.
cache_put(Cache, Key, Status) ->
case erlang:element(3, Cache) =< 0 of
true ->
nil;
false ->
ets:insert(
erlang:element(2, Cache),
{Key,
Status,
telega@internal@utils:current_time_ms() + erlang:element(
3,
Cache
)}
),
nil
end.
-file("src/telega/roles.gleam", 241).
-spec classify(telega@model@types:chat_member()) -> binary().
classify(Member) ->
case Member of
{chat_member_owner_chat_member, _} ->
<<"owner"/utf8>>;
{chat_member_administrator_chat_member, _} ->
<<"admin"/utf8>>;
_ ->
<<"member"/utf8>>
end.
-file("src/telega/roles.gleam", 229).
-spec query_status(telega@client:telegram_client(), integer(), integer()) -> {ok,
binary()} |
{error, telega@error:telega_error()}.
query_status(Client, Chat_id, User_id) ->
_pipe = telega@api:get_chat_member(
Client,
{get_chat_member_parameters, {int, Chat_id}, User_id}
),
gleam@result:map(_pipe, fun classify/1).
-file("src/telega/roles.gleam", 249).
-spec cache_get(role_cache(), binary()) -> gleam@option:option(binary()).
cache_get(Cache, Key) ->
case erlang:element(3, Cache) =< 0 of
true ->
none;
false ->
case ets:lookup(erlang:element(2, Cache), Key) of
[{_, Status, Expires_at} | _] ->
case telega@internal@utils:current_time_ms() < Expires_at of
true ->
{some, Status};
false ->
ets:delete(erlang:element(2, Cache), Key),
none
end;
_ ->
none
end
end.
-file("src/telega/roles.gleam", 206).
-spec status_of(
role_cache(),
telega@client:telegram_client(),
integer(),
integer()
) -> binary().
status_of(Cache, Client, Chat_id, User_id) ->
Key = <<<<(erlang:integer_to_binary(Chat_id))/binary, ":"/utf8>>/binary,
(erlang:integer_to_binary(User_id))/binary>>,
case cache_get(Cache, Key) of
{some, Status} ->
Status;
none ->
case query_status(Client, Chat_id, User_id) of
{ok, Status@1} ->
cache_put(Cache, Key, Status@1),
Status@1;
{error, _} ->
<<"member"/utf8>>
end
end.
-file("src/telega/roles.gleam", 88).
?DOC(" `True` if the user is an administrator or the owner of the chat.\n").
-spec is_admin(
role_cache(),
telega@client:telegram_client(),
integer(),
integer()
) -> boolean().
is_admin(Cache, Client, Chat_id, User_id) ->
Status = status_of(Cache, Client, Chat_id, User_id),
(Status =:= <<"owner"/utf8>>) orelse (Status =:= <<"admin"/utf8>>).
-file("src/telega/roles.gleam", 99).
?DOC(" `True` if the user is the owner (creator) of the chat.\n").
-spec is_owner(
role_cache(),
telega@client:telegram_client(),
integer(),
integer()
) -> boolean().
is_owner(Cache, Client, Chat_id, User_id) ->
status_of(Cache, Client, Chat_id, User_id) =:= <<"owner"/utf8>>.
-file("src/telega/roles.gleam", 115).
?DOC(
" `use`-friendly guard: run `next` only if the user is an admin/owner of the\n"
" current chat, otherwise run `on_denied`.\n"
"\n"
" ```gleam\n"
" use ctx <- roles.ensure_admin(ctx, cache, on_denied: deny)\n"
" // admin-only body\n"
" ```\n"
).
-spec ensure_admin(
telega@bot:context(BNZL, BNZM, BNZN),
role_cache(),
fun((telega@bot:context(BNZL, BNZM, BNZN)) -> {ok,
telega@bot:context(BNZL, BNZM, BNZN)} |
{error, BNZM}),
fun((telega@bot:context(BNZL, BNZM, BNZN)) -> {ok,
telega@bot:context(BNZL, BNZM, BNZN)} |
{error, BNZM})
) -> {ok, telega@bot:context(BNZL, BNZM, BNZN)} | {error, BNZM}.
ensure_admin(Ctx, Cache, On_denied, Next) ->
case is_admin(
Cache,
erlang:element(5, erlang:element(4, Ctx)),
erlang:element(3, erlang:element(3, Ctx)),
erlang:element(2, erlang:element(3, Ctx))
) of
true ->
Next(Ctx);
false ->
On_denied(Ctx)
end.
-file("src/telega/roles.gleam", 138).
?DOC(
" `use`-friendly guard: run `next` only if the user owns the current chat,\n"
" otherwise run `on_denied`.\n"
).
-spec ensure_owner(
telega@bot:context(BOAM, BOAN, BOAO),
role_cache(),
fun((telega@bot:context(BOAM, BOAN, BOAO)) -> {ok,
telega@bot:context(BOAM, BOAN, BOAO)} |
{error, BOAN}),
fun((telega@bot:context(BOAM, BOAN, BOAO)) -> {ok,
telega@bot:context(BOAM, BOAN, BOAO)} |
{error, BOAN})
) -> {ok, telega@bot:context(BOAM, BOAN, BOAO)} | {error, BOAN}.
ensure_owner(Ctx, Cache, On_denied, Next) ->
case is_owner(
Cache,
erlang:element(5, erlang:element(4, Ctx)),
erlang:element(3, erlang:element(3, Ctx)),
erlang:element(2, erlang:element(3, Ctx))
) of
true ->
Next(Ctx);
false ->
On_denied(Ctx)
end.
-file("src/telega/roles.gleam", 173).
?DOC(
" Router middleware that gates every route on admin/owner status. Non-admins\n"
" are handed to `on_denied` instead of the matched handler.\n"
"\n"
" Apply it to a dedicated admin sub-router and compose it with your main\n"
" router so only those routes are gated:\n"
"\n"
" ```gleam\n"
" let admin =\n"
" router.new(\"admin\")\n"
" |> router.use_middleware(roles.require_admin(cache:, on_denied: deny))\n"
" |> router.on_command(\"ban\", ban_handler)\n"
"\n"
" router.compose(main_router, admin)\n"
" ```\n"
).
-spec require_admin(
role_cache(),
fun((telega@bot:context(BOBN, BOBO, BOBP)) -> {ok,
telega@bot:context(BOBN, BOBO, BOBP)} |
{error, BOBO})
) -> fun((fun((telega@bot:context(BOBN, BOBO, BOBP), telega@update:update()) -> {ok,
telega@bot:context(BOBN, BOBO, BOBP)} |
{error, BOBO})) -> fun((telega@bot:context(BOBN, BOBO, BOBP), telega@update:update()) -> {ok,
telega@bot:context(BOBN, BOBO, BOBP)} |
{error, BOBO})).
require_admin(Cache, On_denied) ->
fun(Handler) ->
fun(Ctx, Upd) ->
case is_admin(
Cache,
erlang:element(5, erlang:element(4, Ctx)),
erlang:element(3, Upd),
erlang:element(2, Upd)
) of
true ->
Handler(Ctx, Upd);
false ->
On_denied(Ctx)
end
end
end.
-file("src/telega/roles.gleam", 189).
?DOC(" Router middleware that gates every route on owner (creator) status.\n").
-spec require_owner(
role_cache(),
fun((telega@bot:context(BOCB, BOCC, BOCD)) -> {ok,
telega@bot:context(BOCB, BOCC, BOCD)} |
{error, BOCC})
) -> fun((fun((telega@bot:context(BOCB, BOCC, BOCD), telega@update:update()) -> {ok,
telega@bot:context(BOCB, BOCC, BOCD)} |
{error, BOCC})) -> fun((telega@bot:context(BOCB, BOCC, BOCD), telega@update:update()) -> {ok,
telega@bot:context(BOCB, BOCC, BOCD)} |
{error, BOCC})).
require_owner(Cache, On_denied) ->
fun(Handler) ->
fun(Ctx, Upd) ->
case is_owner(
Cache,
erlang:element(5, erlang:element(4, Ctx)),
erlang:element(3, Upd),
erlang:element(2, Upd)
) of
true ->
Handler(Ctx, Upd);
false ->
On_denied(Ctx)
end
end
end.