190 lines
6.1 KiB
Elixir
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
|