-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(BNZF, BNZG, BNZH), role_cache(), fun((telega@bot:context(BNZF, BNZG, BNZH)) -> {ok, telega@bot:context(BNZF, BNZG, BNZH)} | {error, BNZG}), fun((telega@bot:context(BNZF, BNZG, BNZH)) -> {ok, telega@bot:context(BNZF, BNZG, BNZH)} | {error, BNZG}) ) -> {ok, telega@bot:context(BNZF, BNZG, BNZH)} | {error, BNZG}. 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(BOAG, BOAH, BOAI), role_cache(), fun((telega@bot:context(BOAG, BOAH, BOAI)) -> {ok, telega@bot:context(BOAG, BOAH, BOAI)} | {error, BOAH}), fun((telega@bot:context(BOAG, BOAH, BOAI)) -> {ok, telega@bot:context(BOAG, BOAH, BOAI)} | {error, BOAH}) ) -> {ok, telega@bot:context(BOAG, BOAH, BOAI)} | {error, BOAH}. 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(BOBH, BOBI, BOBJ)) -> {ok, telega@bot:context(BOBH, BOBI, BOBJ)} | {error, BOBI}) ) -> fun((fun((telega@bot:context(BOBH, BOBI, BOBJ), telega@update:update()) -> {ok, telega@bot:context(BOBH, BOBI, BOBJ)} | {error, BOBI})) -> fun((telega@bot:context(BOBH, BOBI, BOBJ), telega@update:update()) -> {ok, telega@bot:context(BOBH, BOBI, BOBJ)} | {error, BOBI})). 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(BOBV, BOBW, BOBX)) -> {ok, telega@bot:context(BOBV, BOBW, BOBX)} | {error, BOBW}) ) -> fun((fun((telega@bot:context(BOBV, BOBW, BOBX), telega@update:update()) -> {ok, telega@bot:context(BOBV, BOBW, BOBX)} | {error, BOBW})) -> fun((telega@bot:context(BOBV, BOBW, BOBX), telega@update:update()) -> {ok, telega@bot:context(BOBV, BOBW, BOBX)} | {error, BOBW})). 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.