dexcord/lib/dexcord.ex

190 lines
6.1 KiB
Elixir

defmodule Dexcord do
@moduledoc """
A reliability-first Discord bot library for Elixir.
Dexcord is a library, not an auto-starting application - you add a single child
to your own supervision tree:
children = [
{Dexcord, token: System.fetch_env!("DISCORD_TOKEN"),
handler: MyBot.Handler,
intents: :all}
]
## Options
* `:token` (required) - the bot token
* `:handler` (required) - a module using `Dexcord.Handler`
* `:intents` - `:all`, `:default`, a list of intent atoms, or an integer
bitmask (default `:default`); see `Dexcord.Intents`
* `:cache_presences` - cache PRESENCE_UPDATE (default `false`)
* `:request_guild_members` - request full member lists on GUILD_CREATE
(default `false`)
* `:slash` - a module using `Dexcord.Slash`. When set, `Dexcord.Slash.Registrar`
registers its `commands/0` at startup and `INTERACTION_CREATE`s are
auto-routed to it (the raw event still reaches the handler).
* `:slash_guild_ids` - a list of guild ids (strings or integers). When present,
slash commands are registered per guild (instant) instead of globally (~1h).
* `:gateway_url` - override the gateway URL (e.g. `"ws://127.0.0.1:4000"`);
when set, `GET /gateway/bot` is skipped. Primarily for testing.
`child_spec/1` validates these options and stashes the resolved config in
`Dexcord.Config` (`:persistent_term`) before the supervision tree starts.
"""
@doc "Sends a presence update (op 3) through the gateway's send budget."
@spec update_presence(map()) :: :ok
defdelegate update_presence(presence), to: Dexcord.Gateway
@doc """
Builds the supervisor child spec, validating options first.
Raises `ArgumentError` on missing/invalid required options.
"""
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
config = validate(opts)
%{
id: __MODULE__,
start: {Dexcord.Supervisor, :start_link, [config]},
type: :supervisor
}
end
@doc false
def validate(opts) do
token = require_opt(opts, :token)
unless is_binary(token) do
raise ArgumentError, "Dexcord :token must be a string"
end
handler = require_opt(opts, :handler)
unless is_atom(handler) do
raise ArgumentError, "Dexcord :handler must be a module"
end
intents = Keyword.get(opts, :intents, :default)
intents_bits = resolve_intents(intents)
cache_presences = boolean_opt(opts, :cache_presences, false)
request_guild_members = boolean_opt(opts, :request_guild_members, false)
gateway_url = Keyword.get(opts, :gateway_url)
validate_gateway_url(gateway_url)
slash = Keyword.get(opts, :slash)
validate_slash(slash)
slash_guild_ids = Keyword.get(opts, :slash_guild_ids)
validate_slash_guild_ids(slash_guild_ids)
allowed_mentions = validate_allowed_mentions(Keyword.get(opts, :allowed_mentions))
%{
token: token,
handler: handler,
intents: intents_bits,
intents_spec: intents,
cache_presences: cache_presences,
request_guild_members: request_guild_members,
slash: slash,
slash_guild_ids: slash_guild_ids,
gateway_url: gateway_url,
allowed_mentions: allowed_mentions
}
end
# `Intents.resolve/1` raises a `FunctionClauseError` for a wrong-typed spec
# (a tuple, a negative integer, ...). Convert that into a friendly message; a
# list carrying an unknown intent already raises a helpful `ArgumentError`,
# which we let through.
defp resolve_intents(intents) do
Dexcord.Intents.resolve(intents)
rescue
FunctionClauseError ->
reraise ArgumentError,
[
message:
"Dexcord :intents must be :all, :default, a list of intent atoms, or a " <>
"non-negative integer, got: #{inspect(intents)}"
],
__STACKTRACE__
end
defp validate_gateway_url(nil), do: :ok
defp validate_gateway_url(url) when is_binary(url) do
unless String.starts_with?(url, ["ws://", "wss://", "http://", "https://"]) do
raise ArgumentError,
"Dexcord :gateway_url must start with ws://, wss://, http:// or https://, " <>
"got: #{inspect(url)}"
end
:ok
end
defp validate_gateway_url(other) do
raise ArgumentError,
"Dexcord :gateway_url must be a URL string or nil, got: #{inspect(other)}"
end
defp validate_slash(nil), do: :ok
defp validate_slash(mod) when is_atom(mod), do: :ok
defp validate_slash(other) do
raise ArgumentError, "Dexcord :slash must be a module, got: #{inspect(other)}"
end
defp validate_slash_guild_ids(nil), do: :ok
defp validate_slash_guild_ids(ids) when is_list(ids) do
unless Enum.all?(ids, &(is_binary(&1) or is_integer(&1))) do
raise ArgumentError,
"Dexcord :slash_guild_ids must be a list of guild ids (strings or integers), " <>
"got: #{inspect(ids)}"
end
:ok
end
defp validate_slash_guild_ids(other) do
raise ArgumentError,
"Dexcord :slash_guild_ids must be a list of guild ids (strings or integers), " <>
"got: #{inspect(other)}"
end
# Accepts a keyword, a map, or a `%Dexcord.AllowedMentions{}`, normalized to a
# string-keyed wire map (only explicitly-provided keys). Absent -> nil.
defp validate_allowed_mentions(nil), do: nil
defp validate_allowed_mentions(spec)
when is_list(spec) or is_map(spec) do
Dexcord.AllowedMentions.normalize(spec)
end
defp validate_allowed_mentions(other) do
raise ArgumentError,
"Dexcord :allowed_mentions must be a keyword list, map, or %Dexcord.AllowedMentions{}, " <>
"got: #{inspect(other)}"
end
defp boolean_opt(opts, key, default) do
value = Keyword.get(opts, key, default)
unless is_boolean(value) do
raise ArgumentError, "Dexcord :#{key} must be a boolean, got: #{inspect(value)}"
end
value
end
defp require_opt(opts, key) do
case Keyword.fetch(opts, key) do
{:ok, value} -> value
:error -> raise ArgumentError, "Dexcord requires the :#{key} option"
end
end
end