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