Packages
phoenix_gen_api
2.18.1
2.22.0
2.21.1
2.21.0
2.20.1
2.20.0
2.19.0
2.18.1
2.18.0
2.17.1
2.17.0
2.16.0
2.15.0
2.14.0
2.13.0
2.12.0
2.11.0
2.10.0
2.9.1
2.9.0
2.8.0
2.7.0
2.6.1
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.1
2.1.0
2.0.1
2.0.0
1.2.0
1.1.3
1.1.2
1.1.1
1.1.0
1.0.1
1.0.0
0.2.1
0.2.0
0.1.1
0.1.0
0.0.16
0.0.15
0.0.14
0.0.13
0.0.12
0.0.11
0.0.10
0.0.9
0.0.8
0.0.7
0.0.6
0.0.5
0.0.4
0.0.3
0.0.2
0.0.1
A library for fast develop APIs in Elixir cluster, using Phoenix Channels for transport data, auto pull api configs from service nodes.
Current section
Files
Jump to
Current section
Files
lib/phoenix_gen_api/relay/relay.ex
defmodule PhoenixGenApi.Relay do
@moduledoc """
Relay message feature for PhoenixGenApi.
Provides group-based message relaying where a user sends a message to a group
and all members (including the sender) receive it.
## Group Types
### :public
- Anyone can join immediately as `:active`.
- All members can send and receive messages.
### :private
- New members join with `:pending` status.
- Any existing active member can accept pending members.
- Only `:active` members can send and receive messages.
### :strict_private
- New members join with `:pending` status.
- Only `:admin` members can accept pending members.
- Admins can `:mute` and `:unmute` members.
- Muted members can receive but cannot send messages.
## Architecture
- **RelayServer** (`PhoenixGenApi.RelayServer`) is a GenServer that owns the
ETS table and serializes all group operations to prevent race conditions.
All public API functions in this module delegate to the GenServer.
- **ETS table** (`:phoenix_gen_api_relay_groups`) stores group metadata:
`{group_id, group_type, members_map}` where `members_map` is
`%{user_id => %{roles: MapSet, status: atom, joined_at: DateTime}}`.
- **Registry** (`PhoenixGenApi.RelayRegistry`) with `:duplicate` keys maps
`group_id` to `{user_id, channel_pid}` for dispatching messages to channel
processes via `send/2`.
"""
alias PhoenixGenApi.Structs.Response
require Logger
@table :phoenix_gen_api_relay_groups
@registry PhoenixGenApi.RelayRegistry
# Threshold below which sequential dispatch is used (avoids Task overhead for small groups).
@relay_dispatch_threshold 32
# Max concurrent Task.async_stream workers for large group dispatch.
@relay_max_concurrency 16
@type group_type :: :public | :private | :strict_private
@type member_status :: :active | :pending | :muted
@type member_info :: %{
roles: MapSet.t(atom()),
status: member_status(),
joined_at: DateTime.t()
}
# ── ETS table access (used by RelayServer to create the table) ──
@doc "Returns the ETS table name for group metadata storage."
@spec table :: atom()
def table, do: @table
# ── Group Lifecycle ────────────────────────────────────────────
@doc """
Creates a new group. The creator becomes the admin with `:active` status.
Returns `:ok` or `{:error, :already_exists}`.
"""
@spec create_group(String.t(), group_type(), String.t(), pid()) ::
:ok | {:error, :already_exists}
def create_group(group_id, group_type, creator_user_id, channel_pid)
when is_binary(group_id) and group_type in [:public, :private, :strict_private] and
is_binary(creator_user_id) and is_pid(channel_pid) do
PhoenixGenApi.RelayServer.create_group(group_id, group_type, creator_user_id, channel_pid)
end
@doc """
Deletes a group from ETS. Registry entries are cleaned up when channel
processes terminate.
Returns `:ok` or `{:error, :not_found}`.
"""
@spec delete_group(String.t()) :: :ok | {:error, :not_found}
def delete_group(group_id) when is_binary(group_id) do
PhoenixGenApi.RelayServer.delete_group(group_id)
end
@doc """
Returns group info including type and members map.
"""
@spec get_group_info(String.t()) :: {:ok, map()} | {:error, :not_found}
def get_group_info(group_id) when is_binary(group_id) do
PhoenixGenApi.RelayServer.get_group_info(group_id)
end
# ── Membership ─────────────────────────────────────────────────
@doc """
Joins a user to a group.
- **public**: user becomes `:active` immediately.
- **private** / **strict_private**: user becomes `:pending`, needs acceptance.
Returns `{:ok, :active}` or `{:ok, :pending}` depending on group type,
or `{:error, reason}`.
"""
@spec join_group(String.t(), String.t(), pid()) ::
{:ok, :active | :pending} | {:error, :not_found | :already_member}
def join_group(group_id, user_id, channel_pid)
when is_binary(group_id) and is_binary(user_id) and is_pid(channel_pid) do
PhoenixGenApi.RelayServer.join_group(group_id, user_id, channel_pid)
end
@doc """
Removes a user from a group and unregisters them from the Registry.
Returns `:ok` or `{:error, reason}`.
"""
@spec leave_group(String.t(), String.t()) :: :ok | {:error, :not_found | :user_not_in_group}
def leave_group(group_id, user_id)
when is_binary(group_id) and is_binary(user_id) do
PhoenixGenApi.RelayServer.leave_group(group_id, user_id)
end
# ── Accept / Mute / Unmute ─────────────────────────────────────
@doc """
Accepts a pending member into a group.
- **private**: any active member can accept.
- **strict_private**: only admin can accept.
Returns `:ok` or `{:error, reason}`.
"""
@spec accept_member(String.t(), String.t(), String.t()) ::
:ok | {:error, :not_found | :user_not_in_group | :not_admin | :user_not_pending}
def accept_member(group_id, actor_user_id, target_user_id)
when is_binary(group_id) and is_binary(actor_user_id) and is_binary(target_user_id) do
PhoenixGenApi.RelayServer.accept_member(group_id, actor_user_id, target_user_id)
end
@doc """
Mutes a member in a strict_private group. Only admins can mute.
Muted members can receive but cannot send messages.
Returns `:ok` or `{:error, reason}`.
"""
@spec mute_member(String.t(), String.t(), String.t()) ::
:ok
| {:error,
:not_found | :not_strict_private | :user_not_in_group | :not_admin | :cannot_mute}
def mute_member(group_id, actor_user_id, target_user_id)
when is_binary(group_id) and is_binary(actor_user_id) and is_binary(target_user_id) do
PhoenixGenApi.RelayServer.mute_member(group_id, actor_user_id, target_user_id)
end
@doc """
Unmutes a member in a strict_private group. Only admins can unmute.
Returns `:ok` or `{:error, reason}`.
"""
@spec unmute_member(String.t(), String.t(), String.t()) ::
:ok
| {:error,
:not_found | :not_strict_private | :user_not_in_group | :not_admin | :not_muted}
def unmute_member(group_id, actor_user_id, target_user_id)
when is_binary(group_id) and is_binary(actor_user_id) and is_binary(target_user_id) do
PhoenixGenApi.RelayServer.unmute_member(group_id, actor_user_id, target_user_id)
end
# ── Relay Message (MFA target) ────────────────────────────────
@doc """
Handles a relay message request. This is the MFA target configured in
`FunConfig` for `request_type: "relay_msg"`.
Extracts `group_id` and `message` from `request.args`, validates the
sender's membership and permissions, then sends `{:relay_message, response}`
to all group members' channel pids via the Registry.
Returns a `Response` with relay status.
"""
@spec handle_relay(PhoenixGenApi.Structs.Request.t(), PhoenixGenApi.Structs.FunConfig.t()) ::
PhoenixGenApi.Structs.Response.t()
def handle_relay(request, fun_config) do
PhoenixGenApi.RelayServer.handle_relay(request, fun_config)
end
# ── Private Implementation (called by RelayServer) ─────────────
@doc false
@spec do_create_group(String.t(), group_type(), String.t(), pid()) ::
:ok | {:error, :already_exists}
def do_create_group(group_id, group_type, creator_user_id, channel_pid) do
case :ets.lookup(@table, group_id) do
[{^group_id, _, _}] ->
{:error, :already_exists}
[] ->
member = new_member([:admin], :active)
members = %{creator_user_id => member}
:ets.insert(@table, {group_id, group_type, members})
Registry.register(@registry, group_id, {creator_user_id, channel_pid})
Logger.info(
"[Relay] group created: #{group_id}, type: #{group_type}, creator: #{creator_user_id}"
)
:ok
end
end
@doc false
@spec do_delete_group(String.t()) :: :ok | {:error, :not_found}
def do_delete_group(group_id) do
case :ets.lookup(@table, group_id) do
[] ->
{:error, :not_found}
[{^group_id, _, _}] ->
:ets.delete(@table, group_id)
Logger.info("[Relay] group deleted: #{group_id}")
:ok
end
end
@doc false
@spec do_get_group_info(String.t()) :: {:ok, map()} | {:error, :not_found}
def do_get_group_info(group_id) do
case :ets.lookup(@table, group_id) do
[] ->
{:error, :not_found}
[{^group_id, group_type, members}] ->
{:ok, %{group_id: group_id, group_type: group_type, members: members}}
end
end
@doc false
@spec do_join_group(String.t(), String.t(), pid()) ::
{:ok, :active | :pending} | {:error, :not_found | :already_member}
def do_join_group(group_id, user_id, channel_pid) do
case :ets.lookup(@table, group_id) do
[] ->
{:error, :not_found}
[{^group_id, :public, members}] ->
case Map.get(members, user_id) do
nil ->
member = new_member([:member], :active)
:ets.insert(@table, {group_id, :public, Map.put(members, user_id, member)})
Registry.register(@registry, group_id, {user_id, channel_pid})
{:ok, :active}
%{status: :active} ->
{:error, :already_member}
%{status: _old_status} ->
Registry.unregister_match(@registry, group_id, {user_id, :_})
member = new_member([:member], :active)
:ets.insert(@table, {group_id, :public, Map.put(members, user_id, member)})
Registry.register(@registry, group_id, {user_id, channel_pid})
{:ok, :active}
end
[{^group_id, group_type, members}]
when group_type in [:private, :strict_private] ->
case Map.get(members, user_id) do
nil ->
member = new_member([:member], :pending)
:ets.insert(@table, {group_id, group_type, Map.put(members, user_id, member)})
Registry.register(@registry, group_id, {user_id, channel_pid})
{:ok, :pending}
%{status: :active} ->
{:error, :already_member}
%{status: :pending} ->
{:error, :already_member}
%{status: :muted} ->
Registry.unregister_match(@registry, group_id, {user_id, :_})
member = new_member([:member], :pending)
:ets.insert(@table, {group_id, group_type, Map.put(members, user_id, member)})
Registry.register(@registry, group_id, {user_id, channel_pid})
{:ok, :pending}
end
end
end
@doc false
@spec do_leave_group(String.t(), String.t()) :: :ok | {:error, :not_found | :user_not_in_group}
def do_leave_group(group_id, user_id) do
case :ets.lookup(@table, group_id) do
[] ->
{:error, :not_found}
[{^group_id, group_type, members}] ->
if Map.has_key?(members, user_id) do
:ets.insert(@table, {group_id, group_type, Map.delete(members, user_id)})
Registry.unregister_match(@registry, group_id, {user_id, :_})
:ok
else
{:error, :user_not_in_group}
end
end
end
@doc false
@spec do_accept_member(String.t(), String.t(), String.t()) ::
:ok | {:error, :not_found | :user_not_in_group | :not_admin | :user_not_pending}
def do_accept_member(group_id, actor_user_id, target_user_id) do
case :ets.lookup(@table, group_id) do
[] ->
{:error, :not_found}
[{^group_id, group_type, members}] ->
with {:ok, _actor} <- fetch_active_actor(members, actor_user_id, group_type),
{:ok, target} <- fetch_pending_target(members, target_user_id) do
updated = %{target | status: :active}
new_members = Map.put(members, target_user_id, updated)
:ets.insert(@table, {group_id, group_type, new_members})
:ok
end
end
end
@doc false
@spec do_mute_member(String.t(), String.t(), String.t()) ::
:ok
| {:error,
:not_found | :not_strict_private | :user_not_in_group | :not_admin | :cannot_mute}
def do_mute_member(group_id, actor_user_id, target_user_id) do
case :ets.lookup(@table, group_id) do
[] ->
{:error, :not_found}
[{^group_id, :strict_private, members}] ->
with {:ok, _actor} <- fetch_admin_actor(members, actor_user_id),
{:ok, target} <- fetch_active_target(members, target_user_id) do
updated = %{target | status: :muted}
new_members = Map.put(members, target_user_id, updated)
:ets.insert(@table, {group_id, :strict_private, new_members})
:ok
end
[{^group_id, _, _}] ->
{:error, :not_strict_private}
end
end
@doc false
@spec do_unmute_member(String.t(), String.t(), String.t()) ::
:ok
| {:error,
:not_found | :not_strict_private | :user_not_in_group | :not_admin | :not_muted}
def do_unmute_member(group_id, actor_user_id, target_user_id) do
case :ets.lookup(@table, group_id) do
[] ->
{:error, :not_found}
[{^group_id, :strict_private, members}] ->
with {:ok, _actor} <- fetch_admin_actor(members, actor_user_id),
{:ok, target} <- fetch_muted_target(members, target_user_id) do
updated = %{target | status: :active}
new_members = Map.put(members, target_user_id, updated)
:ets.insert(@table, {group_id, :strict_private, new_members})
:ok
end
[{^group_id, _, _}] ->
{:error, :not_strict_private}
end
end
@doc false
@spec do_handle_relay(PhoenixGenApi.Structs.Request.t(), PhoenixGenApi.Structs.FunConfig.t()) ::
PhoenixGenApi.Structs.Response.t()
def do_handle_relay(request, _fun_config) do
group_id = Map.get(request.args, "group_id")
message = Map.get(request.args, "message")
user_id = request.user_id
request_id = request.request_id
case :ets.lookup(@table, group_id) do
[] ->
Response.error_response(request_id, "Group not found")
[{^group_id, _group_type, members}] ->
case Map.get(members, user_id) do
nil ->
Response.error_response(request_id, "Not a member of this group")
%{status: :pending} ->
Response.error_response(request_id, "Pending membership: wait for acceptance")
%{status: :muted} ->
Response.error_response(request_id, "You are muted and cannot send messages")
%{status: :active} ->
recipient_ids =
for {uid, %{status: s}} <- members, s in [:active, :muted], do: uid
relay_payload = %{
"group_id" => group_id,
"from_user_id" => user_id,
"message" => message,
"timestamp" => DateTime.utc_now() |> DateTime.to_iso8601()
}
send_to_group(group_id, request_id, relay_payload)
Response.sync_response(request_id, %{
"status" => "relayed",
"recipients_count" => length(recipient_ids)
})
end
end
end
# ── Private Helpers ────────────────────────────────────────────
defp new_member(roles, status) do
%{
roles: MapSet.new(roles),
status: status,
joined_at: DateTime.utc_now()
}
end
defp fetch_active_actor(members, actor_user_id, group_type) do
case Map.get(members, actor_user_id) do
%{roles: roles, status: :active} ->
if group_type == :strict_private and not MapSet.member?(roles, :admin) do
{:error, :not_admin}
else
{:ok, nil}
end
%{roles: _} ->
{:error, :not_admin}
nil ->
{:error, :user_not_in_group}
end
end
defp fetch_admin_actor(members, actor_user_id) do
case Map.get(members, actor_user_id) do
%{roles: roles, status: :active} ->
if MapSet.member?(roles, :admin) do
{:ok, nil}
else
{:error, :not_admin}
end
_ ->
{:error, :not_admin}
end
end
defp fetch_pending_target(members, target_user_id) do
case Map.get(members, target_user_id) do
%{status: :pending} = target ->
{:ok, target}
nil ->
{:error, :user_not_in_group}
_ ->
{:error, :user_not_pending}
end
end
defp fetch_active_target(members, target_user_id) do
case Map.get(members, target_user_id) do
%{status: :active} = target ->
{:ok, target}
nil ->
{:error, :user_not_in_group}
_ ->
{:error, :cannot_mute}
end
end
defp fetch_muted_target(members, target_user_id) do
case Map.get(members, target_user_id) do
%{status: :muted} = target ->
{:ok, target}
nil ->
{:error, :user_not_in_group}
_ ->
{:error, :not_muted}
end
end
defp send_to_group(group_id, request_id, relay_payload) do
relay_response = Response.sync_response(request_id, relay_payload)
members =
Registry.select(@registry, [
{{:"$1", :"$2", :"$3"}, [{:==, :"$1", group_id}], [{{:"$2", :"$3"}}]}
])
# For small groups, use sequential dispatch (lower overhead).
# For large groups, use Task.async_stream to parallelize sends with back-pressure.
if length(members) <= @relay_dispatch_threshold do
Enum.each(members, fn {_key, {_user_id, channel_pid}} ->
send(channel_pid, {:relay_message, relay_response})
end)
else
Task.async_stream(
members,
fn {_key, {_user_id, channel_pid}} ->
send(channel_pid, {:relay_message, relay_response})
end,
max_concurrency: @relay_max_concurrency,
ordered: false
)
|> Stream.run()
end
end
end