Repository: Kraigie/nostrum
Pull request #584 (2024-05-05 02:18:31)

Add full support for guild stickers
This extends the support we already had in Nostrum for guild stickers by adding
all the missing API routes, structs and so on.

- Add all missing fields to sticker struct and move the enums to atoms
- Add a new `cdn_url/1` method to the sticker struct for fetching the CDN URL
- Add all API routes required to fetch and manage guild stickers
- Add API route for fetching public sticker packs
- Update all caches to be able to handle sticker updates for dispatches to users
- Add tests for all of the above
- Change the permissions to reflect the new naming for
  `manage_guild_expressions` and the addition of the new
  `create_guild_expressions` permission


Patch:
diff --git a/lib/nostrum/api.ex b/lib/nostrum/api.ex
index 616f532f..accf3f1b 100644
--- a/lib/nostrum/api.ex
+++ b/lib/nostrum/api.ex
@@ -41,6 +41,8 @@ defmodule Nostrum.Api do
   ```
   """
 
+  @crlf "\r\n"
+
   import Nostrum.Snowflake, only: [is_snowflake: 1]
 
   alias Nostrum.Api.Ratelimiter
@@ -58,6 +60,7 @@ defmodule Nostrum.Api do
     Invite,
     Message,
     Message.Poll,
+    Sticker,
     ThreadMember,
     User,
     Webhook
@@ -1416,6 +1419,150 @@ defmodule Nostrum.Api do
     |> bangify
   end
 
+  @doc ~S"""
+  Fetch a sticker with the provided ID.
+
+  Returns a `t:Nostrum.Struct.Sticker.t/0`.
+  """
+  @spec get_sticker(Snowflake.t()) :: {:ok, Sticker.t()} | error
+  def get_sticker(sticker_id) do
+    request(:get, Constants.sticker(sticker_id))
+    |> handle_request_with_decode({:struct, Sticker})
+  end
+
+  @doc ~S"""
+  List all stickers in the provided guild.
+
+  Returns a list of `t:Nostrum.Struct.Sticker.t/0`.
+  """
+  @spec list_guild_stickers(Guild.id()) :: {:ok, [Sticker.t()]} | error
+  def list_guild_stickers(guild_id) do
+    request(:get, Constants.guild_stickers(guild_id))
+    |> handle_request_with_decode({:list, {:struct, Sticker}})
+  end
+
+  @doc ~S"""
+  Return the specified sticker from the specified guild.
+
+  Returns a `t:Nostrum.Struct.Sticker.t/0`.
+  """
+  @spec get_guild_sticker(Guild.id(), Sticker.id()) :: Sticker.t() | error
+  def get_guild_sticker(guild_id, sticker_id) do
+    request(:get, Constants.guild_sticker(guild_id, sticker_id))
+    |> handle_request_with_decode({:struct, Sticker})
+  end
+
+  @doc ~S"""
+  Create a sticker in a guild.
+
+  Every guild has five free sticker slots by default, and each Boost level will
+  grant access to more slots.
+
+  Uploaded stickers are constrained to 5 seconds in length for animated stickers, and 320 x 320 pixels.
+
+  Stickers in the [Lottie file format](https://airbnb.design/lottie/) can only
+  be uploaded on guilds that have either the `VERIFIED` and/or the `PARTNERED`
+  guild feature.
+
+  ## Parameters
+
+  - `name`: Name of the sticker (2-30 characters)
+  - `description`: Description of the sticker (2-100 characters)
+  - `tags`: Autocomplete/suggestion tags for the sticker (max 200 characters)
+  - `file`: A path to a file to upload or a map of `name` (file name) and `body` (file data).
+  - `reason` (optional): audit log reason to attach to this event
+
+  ## Returns
+
+  Returns a `t:Nostrum.Struct.Sticker.t/0` on success.
+  """
+  @spec create_guild_sticker(
+          Guild.id(),
+          Sticker.name(),
+          Sticker.description(),
+          Sticker.tags(),
+          String.t() | %{body: iodata(), name: String.t()},
+          String.t() | nil
+        ) :: {:ok, Sticker.t()} | error
+  def create_guild_sticker(guild_id, name, description, tags, file, reason \\ nil) do
+    opts = %{
+      name: name,
+      description: description,
+      tags: tags
+    }
+
+    boundary = generate_boundary()
+
+    multipart = create_multipart([], Jason.encode_to_iodata!(opts), boundary)
+
+    headers =
+      maybe_add_reason(reason, [
+        {"content-type", "multipart/form-data; boundary=#{boundary}"}
+      ])
+
+    file = create_file_part_for_multipart(file, nil, boundary, "file")
+
+    %{
+      method: :post,
+      route: Constants.guild_stickers(guild_id),
+      body:
+        {:multipart,
+         [
+           ~s|--#{boundary}#{@crlf}|,
+           file
+           | multipart
+         ]},
+      params: [],
+      headers: headers
+    }
+    |> request()
+    |> handle_request_with_decode({:struct, Sticker})
+  end
+
+  @doc ~S"""
+  Modify a guild sticker with the specified ID.
+
+  Pass in a map of properties to update, with any of the following keys:
+
+  - `name`: Name of the sticker (2-30 characters)
+  - `description`: Description of the sticker (2-100 characters)
+  - `tags`: Autocomplete/suggestion tags for the sticker (max 200 characters)
+
+  Returns an updated sticker on update completion.
+  """
+  @spec modify_guild_sticker(Guild.id(), Sticker.id(), %{
+          name: Sticker.name() | nil,
+          description: Sticker.description() | nil,
+          tags: Sticker.tags() | nil
+        }) :: {:ok, Sticker.t()} | error
+  def modify_guild_sticker(guild_id, sticker_id, options) do
+    request(:patch, Constants.guild_sticker(guild_id, sticker_id), options)
+    |> handle_request_with_decode({:struct, Sticker})
+  end
+
+  @doc ~S"""
+  Delete a guild sticker with the specified ID.
+  """
+  @spec delete_guild_sticker(Guild.id(), Sticker.id()) :: {:ok} | error
+  def delete_guild_sticker(guild_id, sticker_id) do
+    request(:delete, Constants.guild_sticker(guild_id, sticker_id))
+  end
+
+  @doc ~S"""
+  Get a list of available sticker packs.
+  """
+  @spec get_sticker_packs() :: {:ok, [Sticker.Pack.t()]} | error
+  def get_sticker_packs do
+    resp =
+      request(:get, Constants.sticker_packs())
+      |> handle_request_with_decode()
+
+    case resp do
+      {:ok, %{sticker_packs: packs}} -> {:ok, Util.cast(packs, {:list, {:struct, Sticker.Pack}})}
+      _ -> resp
+    end
+  end
+
   @doc ~S"""
   Get the `t:Nostrum.Struct.Guild.AuditLog.t/0` for the given `guild_id`.
 
@@ -4373,8 +4520,6 @@ defmodule Nostrum.Api do
     end
   end
 
-  @crlf "\r\n"
-
   defp create_multipart(files, json, boundary) do
     json_mime = MIME.type("json")
     json_size = :erlang.iolist_size(json)
@@ -4397,16 +4542,23 @@ defmodule Nostrum.Api do
     ]
   end
 
-  defp create_file_part_for_multipart(file, index, boundary) do
+  defp create_file_part_for_multipart(file, index, boundary, name_override \\ nil) do
     {body, name} = get_file_contents(file)
 
     file_mime = MIME.from_path(name)
     file_size = :erlang.iolist_size(body)
 
+    field_name =
+      if name_override do
+        name_override
+      else
+        "files[#{index}]"
+      end
+
     [
       ~s|content-length: #{file_size}#{@crlf}|,
       ~s|content-type: #{file_mime}#{@crlf}|,
-      ~s|content-disposition: form-data; name="files[#{index}]"; filename="#{name}"#{@crlf}#{@crlf}|,
+      ~s|content-disposition: form-data; name="#{field_name}"; filename="#{name}"#{@crlf}#{@crlf}|,
       body,
       ~s|#{@crlf}--#{boundary}#{@crlf}|
     ]
diff --git a/lib/nostrum/cache/guild_cache.ex b/lib/nostrum/cache/guild_cache.ex
index c6931233..95054a07 100644
--- a/lib/nostrum/cache/guild_cache.ex
+++ b/lib/nostrum/cache/guild_cache.ex
@@ -42,6 +42,7 @@ defmodule Nostrum.Cache.GuildCache do
   alias Nostrum.Struct.Emoji
   alias Nostrum.Struct.Guild
   alias Nostrum.Struct.Guild.Role
+  alias Nostrum.Struct.Sticker
   alias Nostrum.Util
 
   @configured_cache :nostrum
@@ -146,6 +147,16 @@ defmodule Nostrum.Cache.GuildCache do
   @callback emoji_update(Guild.id(), emojis :: [map()]) ::
               {old_emojis :: [Emoji.t()], new_emojis :: [Emoji.t()]}
 
+  @doc """
+  Update the sticker list of the given guild from upstream data.
+
+  Discord sends us a complete list of stickers on an update, which is passed here.
+
+  Return the old list of stickers before the update, and the updated list of stickers.
+  """
+  @callback stickers_update(Guild.id(), stickers :: [map()]) ::
+              {old_stickers :: [Sticker.t()], new_stickers :: [Sticker.t()]}
+
   @doc """
   Create a role on the given guild from upstream data.
 
@@ -248,6 +259,8 @@ defmodule Nostrum.Cache.GuildCache do
   @doc false
   defdelegate emoji_update(guild_id, emojis), to: @configured_cache
   @doc false
+  defdelegate stickers_update(guild_id, stickers), to: @configured_cache
+  @doc false
   defdelegate role_create(guild_id, role), to: @configured_cache
   @doc false
   defdelegate role_delete(guild_id, role), to: @configured_cache
diff --git a/lib/nostrum/cache/guild_cache/ets.ex b/lib/nostrum/cache/guild_cache/ets.ex
index dbdf2605..62c11d55 100644
--- a/lib/nostrum/cache/guild_cache/ets.ex
+++ b/lib/nostrum/cache/guild_cache/ets.ex
@@ -24,6 +24,7 @@ defmodule Nostrum.Cache.GuildCache.ETS do
   alias Nostrum.Struct.Emoji
   alias Nostrum.Struct.Guild
   alias Nostrum.Struct.Guild.Role
+  alias Nostrum.Struct.Sticker
   alias Nostrum.Util
   use Supervisor
 
@@ -124,6 +125,17 @@ defmodule Nostrum.Cache.GuildCache.ETS do
     {guild.emojis, casted}
   end
 
+  @doc "Update the sticker list for the given guild in the cache."
+  @impl GuildCache
+  @spec stickers_update(Guild.id(), [map()]) :: {[Sticker.t()], [Sticker.t()]}
+  def stickers_update(guild_id, stickers) do
+    [{_id, guild}] = :ets.lookup(@table_name, guild_id)
+    casted = Util.cast(stickers, {:list, {:struct, Sticker}})
+    new = %{guild | stickers: casted}
+    true = :ets.update_element(@table_name, guild_id, {2, new})
+    {guild.stickers, casted}
+  end
+
   @doc "Create the given role in the given guild in the cache."
   @impl GuildCache
   @spec role_create(Guild.id(), map()) :: {Guild.id(), Role.t()}
diff --git a/lib/nostrum/cache/guild_cache/mnesia.ex b/lib/nostrum/cache/guild_cache/mnesia.ex
index bc7f272f..04f27955 100644
--- a/lib/nostrum/cache/guild_cache/mnesia.ex
+++ b/lib/nostrum/cache/guild_cache/mnesia.ex
@@ -17,6 +17,7 @@ if Code.ensure_loaded?(:mnesia) do
     alias Nostrum.Struct.Emoji
     alias Nostrum.Struct.Guild
     alias Nostrum.Struct.Guild.Role
+    alias Nostrum.Struct.Sticker
     alias Nostrum.Util
     use Supervisor
 
@@ -161,6 +162,20 @@ if Code.ensure_loaded?(:mnesia) do
       {old_emojis, new_emojis}
     end
 
+    @impl GuildCache
+    @doc "Update the sticker list for the given guild in the cache."
+    @spec stickers_update(Guild.id(), [map()]) :: {[Sticker.t()], [Sticker.t()]}
+    def stickers_update(guild_id, stickers) do
+      new_stickers = Util.cast(stickers, {:list, {:struct, Sticker}})
+
+      old_stickers =
+        update_guild!(guild_id, fn guild ->
+          {%{guild | stickers: new_stickers}, guild.stickers}
+        end)
+
+      {old_stickers, new_stickers}
+    end
+
     @impl GuildCache
     @doc "Create the given role in the given guild in the cache."
     @spec role_create(Guild.id(), map()) :: {Guild.id(), Role.t()}
diff --git a/lib/nostrum/cache/guild_cache/noop.ex b/lib/nostrum/cache/guild_cache/noop.ex
index b08a609b..b87909c2 100644
--- a/lib/nostrum/cache/guild_cache/noop.ex
+++ b/lib/nostrum/cache/guild_cache/noop.ex
@@ -53,6 +53,12 @@ defmodule Nostrum.Cache.GuildCache.NoOp do
     {[], casted}
   end
 
+  @impl GuildCache
+  def stickers_update(_guild_id, stickers) do
+    casted = Util.cast(stickers, {:list, {:struct, Sticker}})
+    {[], casted}
+  end
+
   @impl GuildCache
   def role_create(guild_id, role), do: {guild_id, Util.cast(role, {:struct, Role})}
 
diff --git a/lib/nostrum/constants.ex b/lib/nostrum/constants.ex
index 040403a8..297adf2c 100644
--- a/lib/nostrum/constants.ex
+++ b/lib/nostrum/constants.ex
@@ -5,6 +5,7 @@ defmodule Nostrum.Constants do
   def base_route, do: "/api/v10"
   def base_url, do: "https://#{domain()}#{base_route()}"
   def cdn_url, do: "https://cdn.discordapp.com"
+  def media_url, do: "https://media.discordapp.net"
   def gateway, do: "/gateway"
   def gateway_bot, do: "/gateway/bot"
 
@@ -67,6 +68,12 @@ defmodule Nostrum.Constants do
   def guild_emojis(guild_id), do: "/guilds/#{guild_id}/emojis"
   def guild_emoji(guild_id, emoji_id), do: "/guilds/#{guild_id}/emojis/#{emoji_id}"
 
+  def sticker(sticker_id), do: "/stickers/#{sticker_id}"
+  def guild_stickers(guild_id), do: "/guilds/#{guild_id}/stickers"
+  def guild_sticker(guild_id, sticker_id), do: "/guilds/#{guild_id}/stickers/#{sticker_id}"
+
+  def sticker_packs, do: "/sticker-packs"
+
   def guild_scheduled_events(guild_id), do: "/guilds/#{guild_id}/scheduled-events"
 
   def guild_scheduled_event(guild_id, event_id),
@@ -152,6 +159,9 @@ defmodule Nostrum.Constants do
     "/guilds/#{guild_id}/users/#{user_id}/avatars/#{avatar_hash}.#{image_format}"
   end
 
+  def cdn_sticker(id, image_format), do: "/stickers/#{id}.#{image_format}"
+  def cdn_sticker_pack(id), do: "/app-assets/710982414301790216/store/#{id}.png"
+
   def thread_with_message(channel_id, message_id),
     do: "/channels/#{channel_id}/messages/#{message_id}/threads"
 
diff --git a/lib/nostrum/permission.ex b/lib/nostrum/permission.ex
index 6aa23c2d..29d81fe0 100644
--- a/lib/nostrum/permission.ex
+++ b/lib/nostrum/permission.ex
@@ -44,10 +44,11 @@ defmodule Nostrum.Permission do
           | :manage_nicknames
           | :manage_roles
           | :manage_webhooks
-          | :manage_emojis_and_stickers
+          | :manage_guild_expressions
           | :view_guild_insights
           | :use_application_commands
           | :moderate_members
+          | :create_guild_expressions
           | :send_polls
 
   @type text_permission ::
@@ -115,7 +116,7 @@ defmodule Nostrum.Permission do
     manage_nicknames: 1 <<< 27,
     manage_roles: 1 <<< 28,
     manage_webhooks: 1 <<< 29,
-    manage_emojis_and_stickers: 1 <<< 30,
+    manage_guild_expressions: 1 <<< 30,
     use_application_commands: 1 <<< 31,
     request_to_speak: 1 <<< 32,
     manage_events: 1 <<< 33,
@@ -126,9 +127,14 @@ defmodule Nostrum.Permission do
     send_messages_in_threads: 1 <<< 38,
     use_embedded_activities: 1 <<< 39,
     moderate_members: 1 <<< 40,
+    create_guild_expressions: 1 <<< 43,
     send_polls: 1 <<< 49
   }
 
+  @legacy_perm_names %{
+    manage_emojis_and_stickers: :manage_guild_expressions
+  }
+
   @bit_to_permission_map Map.new(@permission_to_bit_map, fn {k, v} -> {v, k} end)
   @permission_list Map.keys(@permission_to_bit_map)
 
@@ -233,6 +239,9 @@ defmodule Nostrum.Permission do
   ```
   """
   @spec to_bit(t) :: bit
+  def to_bit(permission) when is_map_key(@legacy_perm_names, permission),
+    do: to_bit(@legacy_perm_names[permission])
+
   def to_bit(permission) when is_permission(permission), do: @permission_to_bit_map[permission]
 
   @doc """
diff --git a/lib/nostrum/shard/dispatch.ex b/lib/nostrum/shard/dispatch.ex
index f1b7c991..56656f83 100644
--- a/lib/nostrum/shard/dispatch.ex
+++ b/lib/nostrum/shard/dispatch.ex
@@ -171,6 +171,9 @@ defmodule Nostrum.Shard.Dispatch do
   def handle_event(:GUILD_EMOJIS_UPDATE = event, p, state),
     do: {event, GuildCache.emoji_update(p.guild_id, p.emojis), state}
 
+  def handle_event(:GUILD_STICKERS_UPDATE = event, p, state),
+    do: {event, GuildCache.stickers_update(p.guild_id, p.stickers), state}
+
   def handle_event(:GUILD_INTEGRATIONS_UPDATE = event, p, state) do
     {event, GuildIntegrationsUpdate.to_struct(p), state}
   end
diff --git a/lib/nostrum/shard/session.ex b/lib/nostrum/shard/session.ex
index c724e0c5..2b8cc1e7 100644
--- a/lib/nostrum/shard/session.ex
+++ b/lib/nostrum/shard/session.ex
@@ -337,27 +337,6 @@ defmodule Nostrum.Shard.Session do
     {:next_state, :disconnected, %{data | conn: nil, stream: nil}, connect}
   end
 
-  # :gen_statem callback helper that removes the huge zlib data blob from any
-  # errors experienced by the shard session state machine. This zlib blob is
-  # only decodable by the zlib context that the error ocurred in anyway so it's
-  # inclusion in bug reports only hinders. It has also already been decoded by
-  # the time it reaches this call so we cannot attempt to decode it here.
-  def format_status(%{queue: queue} = state) do
-    queue =
-      Enum.map(queue, fn queue_item ->
-        case queue_item do
-          # match gun websocket messages and remove the data blob
-          {:info, {:gun_ws, conn, stream, {:binary, _payload}}} ->
-            {:info, {:gun_ws, conn, stream, {:binary, "PAYLOAD REMOVED"}}}
-
-          _ ->
-            queue_item
-        end
-      end)
-
-    %{state | queue: queue}
-  end
-
   # Internal helper. Wait for consumers to start up.
   defp wait_for_consumer_boot(reference, timeout) do
     receive do
diff --git a/lib/nostrum/struct/guild.ex b/lib/nostrum/struct/guild.ex
index 04c00526..7c0e688f 100644
--- a/lib/nostrum/struct/guild.ex
+++ b/lib/nostrum/struct/guild.ex
@@ -15,7 +15,7 @@ defmodule Nostrum.Struct.Guild do
   Struct representing a Discord guild.
   """
 
-  alias Nostrum.Struct.{Channel, Emoji, User}
+  alias Nostrum.Struct.{Channel, Emoji, Sticker, User}
   alias Nostrum.Struct.Guild.{Role, ScheduledEvent}
   alias Nostrum.{Constants, Snowflake, Util}
 
@@ -49,7 +49,8 @@ defmodule Nostrum.Struct.Guild do
     :channels,
     :guild_scheduled_events,
     :vanity_url_code,
-    :threads
+    :threads,
+    :stickers
   ]
 
   @typedoc "The guild's id"
@@ -161,6 +162,9 @@ defmodule Nostrum.Struct.Guild do
   @typedoc since: "0.5.1"
   @type threads :: %{required(Channel.id()) => Channel.t()} | nil
 
+  @typedoc "Custom stickers registered to the guild"
+  @type stickers :: [Sticker.t()]
+
   @typedoc """
   A `Nostrum.Struct.Guild` that is sent on user-specific rest endpoints.
   """
@@ -193,7 +197,8 @@ defmodule Nostrum.Struct.Guild do
           voice_states: nil,
           channels: nil,
           vanity_url_code: nil,
-          threads: nil
+          threads: nil,
+          stickers: nil
         }
 
   @typedoc """
@@ -222,6 +227,7 @@ defmodule Nostrum.Struct.Guild do
           rules_channel_id: rules_channel_id,
           public_updates_channel_id: public_updates_channel_id,
           vanity_url_code: vanity_url_code,
+          stickers: stickers,
           joined_at: nil,
           large: nil,
           unavailable: nil,
@@ -265,7 +271,8 @@ defmodule Nostrum.Struct.Guild do
           channels: nil,
           guild_scheduled_events: nil,
           vanity_url_code: nil,
-          threads: nil
+          threads: nil,
+          stickers: nil
         }
 
   @typedoc """
@@ -301,7 +308,8 @@ defmodule Nostrum.Struct.Guild do
           channels: channels,
           guild_scheduled_events: guild_scheduled_events,
           vanity_url_code: vanity_url_code,
-          threads: threads
+          threads: threads,
+          stickers: stickers
         }
 
   @type t ::
@@ -379,6 +387,7 @@ defmodule Nostrum.Struct.Guild do
       |> Map.update(:afk_channel_id, nil, &Util.cast(&1, Snowflake))
       |> Map.update(:roles, nil, &Util.cast(&1, {:index, [:id], {:struct, Role}}))
       |> Map.update(:emojis, nil, &Util.cast(&1, {:list, {:struct, Emoji}}))
+      |> Map.update(:stickers, nil, &Util.cast(&1, {:list, {:struct, Sticker}}))
       |> Map.update(:application_id, nil, &Util.cast(&1, Snowflake))
       |> Map.update(:widget_channel_id, nil, &Util.cast(&1, Snowflake))
       |> Map.update(:system_channel_id, nil, &Util.cast(&1, Snowflake))
diff --git a/lib/nostrum/struct/message.ex b/lib/nostrum/struct/message.ex
index 00f52139..c48cb9a1 100644
--- a/lib/nostrum/struct/message.ex
+++ b/lib/nostrum/struct/message.ex
@@ -6,7 +6,7 @@ defmodule Nostrum.Struct.Message do
   [Discord API Message Object Documentation](https://discord.com/developers/docs/resources/channel#message-object).
   """
 
-  alias Nostrum.Struct.{Channel, Embed, Guild, Interaction, User}
+  alias Nostrum.Struct.{Channel, Embed, Guild, Interaction, Sticker, User}
   alias Nostrum.Struct.Guild.{Member, Role}
 
   alias Nostrum.Struct.Message.{
@@ -16,8 +16,7 @@ defmodule Nostrum.Struct.Message do
     Component,
     Poll,
     Reaction,
-    Reference,
-    Sticker
+    Reference
   }
 
   alias Nostrum.{Snowflake, Util}
diff --git a/lib/nostrum/struct/message/sticker.ex b/lib/nostrum/struct/message/sticker.ex
deleted file mode 100644
index f5777fcb..00000000
--- a/lib/nostrum/struct/message/sticker.ex
+++ /dev/null
@@ -1,114 +0,0 @@
-defmodule Nostrum.Struct.Message.Sticker do
-  @moduledoc """
-  A `Nostrum.Struct.Message.Sticker` represents a sticker that can be sent inside a `Nostrum.Struct.Message`.
-
-  More information can be found on the [Discord API Sticker Object Documentation.](https://discord.com/developers/docs/resources/sticker#sticker-object-sticker-structure)
-  """
-  @moduledoc since: "0.5.0"
-
-  alias Nostrum.Struct.{Guild, User}
-  alias Nostrum.{Snowflake, Util}
-
-  defstruct [
-    :id,
-    :pack_id,
-    :name,
-    :description,
-    :tags,
-    :type,
-    :format_type,
-    :available,
-    :guild_id,
-    :user,
-    :sort_value
-  ]
-
-  @typedoc """
-  Id of the sticker
-  """
-  @type id :: Snowflake.t()
-
-  @typedoc """
-  Id of the pack the sticker is from
-  """
-  @type pack_id :: Snowflake.t()
-
-  @typedoc """
-  Name of the sticker
-  """
-  @type name :: String.t() | nil
-
-  @typedoc """
-  Description of the sticker
-  """
-  @type description :: String.t() | nil
-
-  @typedoc """
-  Discord name of a unicode emoji representing the sticker's expression. for standard stickers, a comma-separated list of related expressions.
-  """
-  @type tags :: String.t() | nil
-
-  @typedoc """
-  [Discord API Sticker Object Type Documentation](https://discord.com/developers/docs/resources/sticker#sticker-object-sticker-types)
-
-  - `1`  - `STANDARD`       an official sticker in a pack, part of Nitro or in a removed purchasable pack
-  - `2`  - `GUILD`          a sticker uploaded to a Boosted guild for the guild's members
-  """
-  @type type :: integer()
-
-  @typedoc """
-  [Discord API Sticker Object Format Type Documentation](https://discord.com/developers/docs/resources/sticker#sticker-object-sticker-format-types)
-
-  - `1`  - `PNG`
-  - `2`  - `APNG`
-  - `3`  - `LOTTIE`
-  """
-  @type format_type :: integer()
-
-  @typedoc """
-  Whether this guild sticker can be used, may be false due to loss of Server Boosts
-  """
-  @type available :: boolean
-
-  @typedoc """
-  Id of the guild that owns this sticker
-  """
-  @type guild_id :: Guild.id() | nil
-
-  @typedoc """
-  User that uploaded the guild sticker
-  """
-  @type user :: User.t()
-
-  @typedoc """
-  The sticker's sort order within its pack
-  """
-  @type sort_value :: integer()
-
-  @type t :: %__MODULE__{
-          id: id,
-          pack_id: pack_id,
-          name: name,
-          description: description,
-          tags: tags,
-          type: type,
-          format_type: format_type,
-          available: available,
-          guild_id: guild_id,
-          user: user,
-          sort_value: sort_value
-        }
-
-  @doc false
-  def to_struct(map) do
-    new =
-      map
-      |> Map.new(fn {k, v} -> {Util.maybe_to_atom(k), v} end)
-      |> Map.update(:id, nil, &Util.cast(&1, Snowflake))
-      |> Map.update(:pack_id, nil, &Util.cast(&1, Snowflake))
-      |> Map.update(:guild_id, nil, &Util.cast(&1, Snowflake))
-      |> Map.update(:user, nil, &Util.cast(&1, {:struct, User}))
-
-    struct(__MODULE__, new)
-  end
-end
diff --git a/lib/nostrum/struct/sticker.ex b/lib/nostrum/struct/sticker.ex
new file mode 100644
index 00000000..88f2399a
--- /dev/null
+++ b/lib/nostrum/struct/sticker.ex
@@ -0,0 +1,174 @@
+defmodule Nostrum.Struct.Sticker do
+  @moduledoc """
+  A `Nostrum.Struct.Sticker` represents a sticker that can be sent inside a
+  `Nostrum.Struct.Message`.
+  """
+
+  alias Nostrum.Struct.{Guild, User}
+  alias Nostrum.{Constants, Snowflake, Util}
+
+  defstruct [
+    :id,
+    :pack_id,
+    :name,
+    :description,
+    :tags,
+    :type,
+    :format_type,
+    :available,
+    :guild_id,
+    :user,
+    :sort_value
+  ]
+
+  @typedoc """
+  ID of the sticker
+  """
+  @type id :: Snowflake.t()
+
+  @typedoc """
+  ID of the pack the sticker is from
+  """
+  @type pack_id :: Snowflake.t()
+
+  @typedoc """
+  Name of the sticker
+  """
+  @type name :: String.t() | nil
+
+  @typedoc """
+  Description of the sticker
+  """
+  @type description :: String.t() | nil
+
+  @typedoc """
+  Tags used by the Discord client to auto-complete a sticker.
+
+  For default sticker packs, this is a comma-separated list. For guild stickers,
+  this is the name of the unicode emoji associated by the sticker creator with
+  the sticker.
+
+  This is technically a free-text field so consistency in formatting is not guaranteed.
+  """
+  @type tags :: String.t()
+
+  @typedoc """
+  Whether the sticker is a standard (platform made) sticker or a custom guild sticker.
+  """
+  @type type :: :standard | :guild
+
+  @typedoc """
+  Format of the sticker.
+
+  This field is used to determine the return URL in `cdn_url/1`.
+  """
+  @type format_type :: :png | :apng | :lottie | :gif
+
+  @typedoc """
+  Whether this guild sticker can be used.
+
+  May be false due to loss of Server Boosts
+  """
+  @type available :: boolean
+
+  @typedoc """
+  ID of the guild that owns this sticker.
+
+  `nil` if the sticker is a built-in (type `:standard`) sticker.
+  """
+  @type guild_id :: Guild.id() | nil
+
+  @typedoc """
+  User that uploaded the guild sticker.
+
+  `nil` if the sticker is a built-in (type `:standard`) sticker.
+  """
+  @type user :: User.t() | nil
+
+  @typedoc """
+  The sticker's sort order within its pack.
+
+  Sometimes provided for stickers with type `:standard` that are in a pack.
+  """
+  @type sort_value :: integer() | nil
+
+  @type t :: %__MODULE__{
+          id: id,
+          pack_id: pack_id,
+          name: name,
+          description: description,
+          tags: tags,
+          type: type,
+          format_type: format_type,
+          available: available,
+          guild_id: guild_id,
+          user: user,
+          sort_value: sort_value
+        }
+
+  @doc false
+  def to_struct(map) do
+    new =
+      map
+      |> Map.new(fn {k, v} -> {Util.maybe_to_atom(k), v} end)
+      |> Map.update(:id, nil, &Util.cast(&1, Snowflake))
+      |> Map.update(:pack_id, nil, &Util.cast(&1, Snowflake))
+      |> Map.update(:guild_id, nil, &Util.cast(&1, Snowflake))
+      |> Map.update(:user, nil, &Util.cast(&1, {:struct, User}))
+      |> Map.update(:format_type, nil, &cast_format_type/1)
+      |> Map.update(:type, nil, &cast_type/1)
+
+    struct(__MODULE__, new)
+  end
+
+  @doc false
+  defp cast_format_type(format_type) do
+    case format_type do
+      1 -> :png
+      2 -> :apng
+      3 -> :lottie
+      4 -> :gif
+    end
+  end
+
+  @doc false
+  defp cast_type(type) do
+    case type do
+      1 -> :standard
+      2 -> :guild
+    end
+  end
+
+  @doc ~S"""
+  Fetch a CDN URL for the sticker object.
+
+  `:png` and `:apng` stickers will return a `.png` URL, `:gif` will return a
+  `.gif` URL and `:lottie` will return a `.json` URL.
+
+  ### Examples
+
+  ```elixir
+  iex> sticker = %Nostrum.Struct.Sticker{format_type: :gif, id: 112233445566778899}
+  iex> Nostrum.Struct.Sticker.cdn_url sticker
+  "https://media.discordapp.net/stickers/112233445566778899.gif"
+  ```
+
+  ```elixir
+  iex> sticker = %Nostrum.Struct.Sticker{format_type: :apng, id: 998877665544332211}
+  iex> Nostrum.Struct.Sticker.cdn_url sticker
+  "https://cdn.discordapp.com/stickers/998877665544332211.png"
+  ```
+  """
+  @spec cdn_url(t()) :: String.t()
+  def cdn_url(%{format_type: :gif, id: id}) do
+    Constants.media_url() <> Constants.cdn_sticker(id, "gif")
+  end
+
+  def cdn_url(%{format_type: :lottie, id: id}) do
+    Constants.cdn_url() <> Constants.cdn_sticker(id, "json")
+  end
+
+  def cdn_url(%{format_type: format, id: id}) when format in [:png, :apng] do
+    Constants.cdn_url() <> Constants.cdn_sticker(id, "png")
+  end
+end
diff --git a/lib/nostrum/struct/sticker/pack.ex b/lib/nostrum/struct/sticker/pack.ex
new file mode 100644
index 00000000..f67c1bf7
--- /dev/null
+++ b/lib/nostrum/struct/sticker/pack.ex
@@ -0,0 +1,95 @@
+defmodule Nostrum.Struct.Sticker.Pack do
+  @moduledoc """
+  Represents a platform-curated sticker pack on Discord
+  """
+
+  defstruct [
+    :id,
+    :stickers,
+    :name,
+    :sku_id,
+    :cover_sticker_id,
+    :description,
+    :banner_asset_id
+  ]
+
+  alias Nostrum.{Constants, Snowflake, Util}
+  alias Nostrum.Struct.Sticker
+
+  @typedoc """
+  ID of the sticker pack.
+  """
+  @type id :: Snowflake.t()
+
+  @typedoc """
+  A list of stickers contained within the pack.
+  """
+  @type stickers :: [Sticker.t()]
+
+  @typedoc """
+  Name of the pack.
+  """
+  @type name :: String.t()
+
+  @typedoc """
+  SKU ID of the sticker pack.
+  """
+  @type sku_id :: Snowflake.t()
+
+  @typedoc """
+  ID of a sticker contained within the pack that should be the cover.
+  """
+  @type cover_sticker_id :: Sticker.id()
+
+  @typedoc """
+  Marketing description of the sticker pack.
+  """
+  @type description :: String.t()
+
+  @typedoc """
+  Asset ID of the banner for this sticker pack.
+  """
+  @type banner_asset_id :: Snowflake.t()
+
+  @type t :: %__MODULE__{
+          id: id,
+          stickers: stickers,
+          name: name,
+          sku_id: sku_id,
+          cover_sticker_id: cover_sticker_id,
+          description: description,
+          banner_asset_id: banner_asset_id
+        }
+
+  @doc false
+  def to_struct(map) do
+    new =
+      map
+      |> Map.new(fn {k, v} -> {Util.maybe_to_atom(k), v} end)
+      |> Map.update(:stickers, nil, &Util.cast(&1, {:list, {:struct, Sticker}}))
+      |> Map.update(:id, nil, &Util.cast(&1, Snowflake))
+      |> Map.update(:sku_id, nil, &Util.cast(&1, Snowflake))
+      |> Map.update(:cover_sticker_id, nil, &Util.cast(&1, Snowflake))
+      |> Map.update(:banner_asset_id, nil, &Util.cast(&1, Snowflake))
+
+    struct(__MODULE__, new)
+  end
+
+  @doc ~S"""
+  Return the banner pack URL for a given sticker pack.
+
+  This is a marketing banner provided by Discord for their platform curated sticker packs.
+
+  ### Examples
+
+  ```elixir
+  iex> pack = %Nostrum.Struct.Sticker.Pack{banner_asset_id: 112233445566778899}
+  iex> Nostrum.Struct.Sticker.Pack.banner_url pack
+  "https://cdn.discordapp.com/app-assets/710982414301790216/store/112233445566778899.png"
+  ```
+  """
+  @spec banner_url(t()) :: String.t()
+  def banner_url(%{banner_asset_id: id}) do
+    Constants.cdn_url() <> Constants.cdn_sticker_pack(id)
+  end
+end


Test patch:
diff --git a/test/nostrum/struct/sticker/pack_test.exs b/test/nostrum/struct/sticker/pack_test.exs
new file mode 100644
index 00000000..70d32466
--- /dev/null
+++ b/test/nostrum/struct/sticker/pack_test.exs
@@ -0,0 +1,7 @@
+defmodule Nostrum.Struct.Sticker.PackTest do
+  use ExUnit.Case, async: true
+
+  alias Nostrum.Struct.Sticker.Pack
+
+  doctest Pack
+end
diff --git a/test/nostrum/struct/sticker_test.exs b/test/nostrum/struct/sticker_test.exs
new file mode 100644
index 00000000..3cea91c9
--- /dev/null
+++ b/test/nostrum/struct/sticker_test.exs
@@ -0,0 +1,7 @@
+defmodule Nostrum.Struct.StickerTest do
+  use ExUnit.Case, async: true
+
+  alias Nostrum.Struct.Sticker
+
+  doctest Sticker
+end