Repository: Kraigie/nostrum
Pull request #573 (2024-05-03 23:19:11)

Add missing fields from member API object
Add some fields that we have missed over recent API changes to the member object.

- `flags`: Flags related to membership screening
- `avatar` and `avatar_url/3` bring support for custom guild avatars
- `pending` is also related to membership screening


Patch:
diff --git a/lib/nostrum/constants.ex b/lib/nostrum/constants.ex
index 851ddcba..040403a8 100644
--- a/lib/nostrum/constants.ex
+++ b/lib/nostrum/constants.ex
@@ -148,6 +148,10 @@ defmodule Nostrum.Constants do
   def cdn_icon(id, icon, image_format), do: "/icons/#{id}/#{icon}.#{image_format}"
   def cdn_splash(id, splash, image_format), do: "/splashes/#{id}/#{splash}.#{image_format}"
 
+  def cdn_guild_avatar(guild_id, user_id, avatar_hash, image_format) do
+    "/guilds/#{guild_id}/users/#{user_id}/avatars/#{avatar_hash}.#{image_format}"
+  end
+
   def thread_with_message(channel_id, message_id),
     do: "/channels/#{channel_id}/messages/#{message_id}/threads"
 
diff --git a/lib/nostrum/struct/guild/member.ex b/lib/nostrum/struct/guild/member.ex
index ac30fe39..e5fa3983 100644
--- a/lib/nostrum/struct/guild/member.ex
+++ b/lib/nostrum/struct/guild/member.ex
@@ -21,21 +21,23 @@ defmodule Nostrum.Struct.Guild.Member do
   ```
   """
 
-  alias Nostrum.Permission
   alias Nostrum.Struct.{Channel, Guild, User}
   alias Nostrum.Struct.Guild.Role
-  alias Nostrum.{Snowflake, Util}
+  alias Nostrum.{Constants, Permission, Snowflake, Util}
   import Bitwise
 
   defstruct [
-    :user_id,
-    :nick,
-    :roles,
-    :joined_at,
+    :avatar,
+    :communication_disabled_until,
     :deaf,
+    :flags,
+    :joined_at,
     :mute,
-    :communication_disabled_until,
-    :premium_since
+    :nick,
+    :pending,
+    :premium_since,
+    :roles,
+    :user_id
   ]
 
   defimpl String.Chars do
@@ -93,15 +95,40 @@ defmodule Nostrum.Struct.Guild.Member do
   """
   @type premium_since :: DateTime.t() | nil
 
+  @typedoc """
+  Avatar hash of the custom avatar set by the user in the guild.
+
+  If animated, this is prefixed with `a_`.
+
+  You can use `avatar_url/3` to fetch a full-formed URL of this asset.
+  """
+  @type avatar :: String.t() | nil
+
+  @typedoc """
+  Current guild member gate status. `false` if user has yet to pass the membership screening
+  configuration for the guild, `true` if the member has passed.
+  """
+  @type pending :: boolean | nil
+
+  @typedoc """
+  Guild member flags represented as a bitset.
+
+  Look at the `Nostrum.Struct.Guild.Member.Flags` module for guidance parsing this value.
+  """
+  @type flags :: pos_integer() | nil
+
   @type t :: %__MODULE__{
-          user_id: user_id,
-          nick: nick,
-          roles: roles,
-          joined_at: joined_at,
+          avatar: avatar,
+          communication_disabled_until: communication_disabled_until,
           deaf: deaf,
+          flags: flags,
+          joined_at: joined_at,
           mute: mute,
-          communication_disabled_until: communication_disabled_until,
-          premium_since: premium_since
+          nick: nick,
+          pending: pending,
+          premium_since: premium_since,
+          roles: roles,
+          user_id: user_id
         }
 
   @doc ~S"""
@@ -120,6 +147,43 @@ defmodule Nostrum.Struct.Guild.Member do
     "<@#{user_id}>"
   end
 
+  @doc ~S"""
+  Returns a guild-specific avatar URL for a `Nostrum.Struct.Guild.Member`.
+
+  Supported formats are `png` (default), `jpg`, `webp` and `gif`.
+
+  As mentioned in the avatar hash typedoc, if the avatar hash begins with `a_`, the
+  avatar is animated and can be returned as a gif.
+
+  ## Examples
+
+  ```elixir
+  iex> member = %Nostrum.Struct.Guild.Member{
+  ...>   user_id: 165023948638126080,
+  ...>   avatar: "4c8319db8ea745275a1399f8f8aa74ab"
+  ...> }
+  iex> guild_id = 1226944827137069107
+  iex> Nostrum.Struct.Guild.Member.avatar_url(member, guild_id)
+  "https://cdn.discordapp.com/guilds/1226944827137069107/users/165023948638126080/avatars/4c8319db8ea745275a1399f8f8aa74ab.png"
+  ```
+  """
+  @spec avatar_url(t, Nostrum.Struct.Guild.id()) :: String.t() | nil
+  def avatar_url(member, guild_id, image_format \\ "png")
+
+  def avatar_url(%{avatar: nil}, _, _) do
+    nil
+  end
+
+  def avatar_url(%{user_id: user_id, avatar: avatar}, guild_id, image_format) do
+    Constants.cdn_url() <>
+      Constants.cdn_guild_avatar(
+        guild_id,
+        user_id,
+        avatar,
+        image_format
+      )
+  end
+
   @doc """
   Returns a member's guild permissions.
 
diff --git a/lib/nostrum/struct/guild/member/flags.ex b/lib/nostrum/struct/guild/member/flags.ex
new file mode 100644
index 00000000..3f32b8b3
--- /dev/null
+++ b/lib/nostrum/struct/guild/member/flags.ex
@@ -0,0 +1,104 @@
+defmodule Nostrum.Struct.Guild.Member.Flags do
+  @moduledoc """
+  Struct representing the flags a guild member can have.
+  """
+
+  import Bitwise
+
+  defstruct did_rejoin: false,
+            completed_onboarding: false,
+            bypasses_verification: false,
+            started_onboarding: false
+
+  @typedoc """
+  Member has left and rejoined the guild
+  """
+  @type did_rejoin :: boolean
+
+  @typedoc """
+  Member has completed onboarding
+  """
+  @type completed_onboarding :: boolean
+
+  @typedoc """
+  Member is exempt from guild verification requirements
+  """
+  @type bypasses_verification :: boolean
+
+  @typedoc """
+  Member has started onboarding
+  """
+  @type started_onboarding :: boolean
+
+  @type flags :: %__MODULE__{
+          did_rejoin: did_rejoin,
+          completed_onboarding: completed_onboarding,
+          bypasses_verification: bypasses_verification,
+          started_onboarding: started_onboarding
+        }
+
+  @type t :: flags
+
+  @flag_values [
+    did_rejoin: 1 <<< 0,
+    completed_onboarding: 1 <<< 1,
+    bypasses_verification: 1 <<< 2,
+    started_onboarding: 1 <<< 3
+  ]
+
+  @doc """
+  Constructs a flag struct based on an integer from the Discord API, normally from `t:Nostrum.Struct.Guild.Member.flags/0`.
+
+  ## Examples
+
+  ```elixir
+  iex> Nostrum.Struct.Guild.Member.Flags.from_integer(9)
+  %Nostrum.Struct.Guild.Member.Flags{
+    did_rejoin: true,
+    completed_onboarding: false,
+    bypasses_verification: false,
+    started_onboarding: true
+  }
+  ```
+  """
+  @spec from_integer(integer()) :: t
+  def from_integer(flag_value) do
+    boolean_list =
+      Enum.map(@flag_values, fn {flag, value} ->
+        {flag, (flag_value &&& value) == value}
+      end)
+
+    struct(__MODULE__, boolean_list)
+  end
+
+  @doc """
+  Convert a flag struct to an integer value.
+
+  ## Examples
+
+  ```elixir
+  iex> my_flags = %Nostrum.Struct.Guild.Member.Flags{
+  ...>   did_rejoin: true,
+  ...>   completed_onboarding: false,
+  ...>   bypasses_verification: false,
+  ...>   started_onboarding: true
+  ...> }
+  iex> Nostrum.Struct.Guild.Member.Flags.to_integer(my_flags)
+  9
+  ```
+  """
+  @spec to_integer(t) :: integer()
+  def to_integer(flag_struct) do
+    booleans =
+      flag_struct
+      |> Map.from_struct()
+      |> Map.to_list()
+
+    Enum.reduce(booleans, 0, fn {flag, enabled}, flag_value ->
+      case enabled do
+        true -> flag_value ||| @flag_values[flag]
+        false -> flag_value
+      end
+    end)
+  end
+end


Test patch:
diff --git a/test/nostrum/struct/guild/member/flags_test.exs b/test/nostrum/struct/guild/member/flags_test.exs
new file mode 100644
index 00000000..48dde2dd
--- /dev/null
+++ b/test/nostrum/struct/guild/member/flags_test.exs
@@ -0,0 +1,7 @@
+defmodule Nostrum.Struct.Guild.Member.FlagsTest do
+  use ExUnit.Case, async: true
+
+  alias Nostrum.Struct.Guild.Member.Flags
+
+  doctest Flags
+end