├── test ├── test_helper.exs ├── alchemy_test.exs ├── Voice │ └── supervisor_test.exs └── Structs │ └── Guild │ └── guild_test.exs ├── .formatter.exs ├── config └── config.exs ├── lib ├── Structs │ ├── Messages │ │ ├── Embed │ │ │ ├── provider.ex │ │ │ ├── field.ex │ │ │ ├── video.ex │ │ │ ├── footer.ex │ │ │ ├── image.ex │ │ │ ├── author.ex │ │ │ ├── thumbnail.ex │ │ │ ├── attachment.ex │ │ │ └── embed.ex │ │ ├── Reactions │ │ │ ├── reaction.ex │ │ │ └── emoji.ex │ │ ├── message_reference.ex │ │ └── message.ex │ ├── Channels │ │ ├── overwrite.ex │ │ ├── Invite │ │ │ ├── invite_channel.ex │ │ │ ├── invite_guild.ex │ │ │ └── invite.ex │ │ ├── dm_channel.ex │ │ ├── group_dm_channel.ex │ │ ├── channel_category.ex │ │ ├── news_channel.ex │ │ ├── voice_channel.ex │ │ ├── text_channel.ex │ │ ├── StageVoiceChannel.ex │ │ ├── store_channel.ex │ │ └── channel.ex │ ├── Users │ │ ├── user_guild.ex │ │ └── user.ex │ ├── Guild │ │ ├── role.ex │ │ ├── guild_member.ex │ │ ├── presence.ex │ │ ├── emoji.ex │ │ ├── integration.ex │ │ └── guild.ex │ ├── Voice │ │ ├── voice_region.ex │ │ ├── voice_state.ex │ │ └── voice.ex │ ├── structs.ex │ ├── webhook.ex │ ├── permissions.ex │ └── audit_log.ex ├── Discord │ ├── types.ex │ ├── Endpoints │ │ ├── invites.ex │ │ ├── users.ex │ │ ├── webhooks.ex │ │ ├── channels.ex │ │ └── guilds.ex │ ├── rate_limits.ex │ ├── Gateway │ │ ├── payloads.ex │ │ ├── gateway.ex │ │ ├── protocol.ex │ │ ├── manager.ex │ │ └── ratelimiter.ex │ ├── rate_manager.ex │ ├── api.ex │ └── events.ex ├── event_macros.ex ├── Cache │ ├── user.ex │ ├── utility.ex │ ├── channels.ex │ ├── supervisor.ex │ ├── priv_channels.ex │ └── guilds.ex ├── Voice │ ├── udp.ex │ ├── supervisor.ex │ ├── gateway.ex │ └── controller.ex ├── EventStage │ ├── event_dispatcher.ex │ ├── tasker.ex │ ├── eventstage.ex │ ├── commandstage.ex │ ├── stage_supervisor.ex │ ├── event_buffer.ex │ └── cacher.ex ├── Cogs │ ├── event_registry.ex │ ├── event_handler.ex │ └── command_handler.ex ├── mix │ └── tasks │ │ └── alchemy │ │ └── init.ex └── cache.ex ├── CHANGELOG.md ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── mix.exs ├── README.md └── docs └── Intro.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: [] 5 | ] 6 | -------------------------------------------------------------------------------- /test/alchemy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AlchemyTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, 4 | backends: [:console] 5 | 6 | config :porcelain, 7 | goon_warn_if_missing: false 8 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed.Provider do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:name, :url] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Channels/overwrite.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.OverWrite do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:id, :type, :allow, :deny] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/field.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed.Field do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:name, :value, :inline] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/video.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed.Video do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:url, :height, :width] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Reactions/reaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Reaction do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:count, :me, :emoji] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/footer.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed.Footer do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:text, :icon_url, :proxy_icon_url] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/image.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed.Image do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:url, :proxy_url, :height, :width] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Users/user_guild.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.UserGuild do 2 | @moduledoc false 3 | 4 | @derive [Poison.Encoder] 5 | defstruct [:id, :name, :icon, :owner, :permissions] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/author.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed.Author do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:name, :url, :icon_url, :proxy_icon_url] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/thumbnail.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed.Thumbnail do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:url, :proxy_url, :height, :width] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Channels/Invite/invite_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.Invite.InviteChannel do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:id, :name, :type] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Channels/Invite/invite_guild.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.Invite.InviteGuild do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:id, :name, :splash, :icon] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Guild/role.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Guild.Role do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:id, :name, :color, :hoist, :position, :permissions, :managed, :mentionable] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Voice/voice_region.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.VoiceRegion do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:id, :name, :sample_hostname, :sample_port, :vip, :optimal, :deprecated, :custom] 6 | end 7 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/attachment.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Attachment do 2 | # documented in Alchemy.Embed 3 | @moduledoc false 4 | 5 | @derive Poison.Encoder 6 | defstruct [:id, :filename, :size, :url, :proxy_url, :height, :width] 7 | end 8 | -------------------------------------------------------------------------------- /lib/Discord/types.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Types do 2 | @moduledoc false 3 | defmacro __using__(_opts) do 4 | quote do 5 | @type snowflake :: String.t() 6 | @type token :: String.t() 7 | @type url :: String.t() 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/Structs/Voice/voice_state.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.VoiceState do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [ 6 | :guild_id, 7 | :channel_id, 8 | :user_id, 9 | :session_id, 10 | :deaf, 11 | :mute, 12 | :self_deaf, 13 | :self_mute, 14 | :suppress 15 | ] 16 | end 17 | -------------------------------------------------------------------------------- /lib/Structs/Guild/guild_member.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Guild.GuildMember do 2 | alias Alchemy.User 3 | import Alchemy.Structs 4 | @moduledoc false 5 | 6 | defstruct [:user, :nick, :roles, :joined_at, :deaf, :mute] 7 | 8 | def from_map(map) do 9 | map 10 | |> field("user", User) 11 | |> to_struct(__MODULE__) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Reactions/emoji.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Reaction.Emoji do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:id, :name] 6 | 7 | @doc false 8 | def resolve(emoji) do 9 | case emoji do 10 | %__MODULE__{} = em -> em 11 | unicode -> %__MODULE__{name: unicode} 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/Structs/Channels/dm_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.DMChannel do 2 | @moduledoc false 3 | alias Alchemy.User 4 | import Alchemy.Structs 5 | 6 | @derive [Poison.Encoder] 7 | defstruct [:id, :recipients, :last_message_id] 8 | 9 | def from_map(map) do 10 | map 11 | |> field_map("recipients", &map_struct(&1, User)) 12 | |> to_struct(__MODULE__) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/Structs/Channels/group_dm_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.GroupDMChannel do 2 | @moduledoc false 3 | alias Alchemy.User 4 | import Alchemy.Structs 5 | 6 | defstruct [:id, :owner_id, :icon, :name, :recipients, :last_message_id] 7 | 8 | def from_map(map) do 9 | map 10 | |> field_map("recipients", &map_struct(&1, User)) 11 | |> to_struct(__MODULE__) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/event_macros.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventMacros do 2 | @moduledoc false 3 | # Since this pattern is repeated for all 20 event handles, having this is conveniant. 4 | defmacro handle(type, func) do 5 | quote bind_quoted: [type: type, func: func] do 6 | quote do 7 | @handles [{unquote(type), {__MODULE__, unquote(func)}} | @handles] 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/Structs/Guild/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Guild.Presence do 2 | import Alchemy.Structs 3 | alias Alchemy.User 4 | @moduledoc false 5 | 6 | @derive Poison.Encoder 7 | defstruct [:user, :roles, :game, :guild_id, :status] 8 | 9 | def from_map(map) do 10 | map 11 | |> field("user", User) 12 | |> field_map?("game", &Map.get(&1, "name")) 13 | |> to_struct(__MODULE__) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/Structs/Channels/channel_category.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.ChannelCategory do 2 | @moduledoc false 3 | alias Alchemy.OverWrite 4 | import Alchemy.Structs 5 | 6 | defstruct [:id, :guild_id, :position, :permission_overwrites, :name, :nsfw] 7 | 8 | def from_map(map) do 9 | map 10 | |> field_map("permission_overwrites", &map_struct(&1, OverWrite)) 11 | |> to_struct(__MODULE__) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/Discord/Endpoints/invites.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Invites do 2 | @moduledoc false 3 | alias Alchemy.Discord.Api 4 | alias Alchemy.Channel.Invite 5 | @root "https://discord.com/api/v6/invites/" 6 | 7 | def get_invite(token, code) do 8 | (@root <> code) 9 | |> Api.get(token, Invite) 10 | end 11 | 12 | def delete_invite(token, code) do 13 | (@root <> code) 14 | |> Api.delete(token) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/Structs/Guild/emoji.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Guild.Emoji do 2 | @moduledoc false 3 | 4 | @derive Poison.Encoder 5 | defstruct [:id, :name, :roles, :require_colons, :managed, :animated, :available] 6 | 7 | defimpl String.Chars, for: __MODULE__ do 8 | def to_string(emoji) do 9 | if emoji.animated do 10 | "" 11 | else 12 | "<:#{emoji.name}:#{emoji.id}>" 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.6.8 2 | - [#120](https://github.com/cronokirby/alchemy/pull/120) Switch to using Hackney, prevent crashes on startup 3 | 4 | # 0.6.5 5 | - [#77](https://github.com/cronokirby/alchemy/pull/77) 6 | Add event handler for role updates. 7 | 8 | # 0.6.4 9 | - [#79](https://github.com/cronokirby/alchemy/issues/79) 10 | Fix race condition that would sometimes cause the READY event 11 | to override data provided in the GUILD_CREATE event, making 12 | it seem like a guild was unavailable when it wasn't. 13 | -------------------------------------------------------------------------------- /lib/Cache/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cache.User do 2 | # Serves as a cache for the user 3 | @moduledoc false 4 | use GenServer 5 | 6 | def start_link do 7 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 8 | end 9 | 10 | def set_user(user) do 11 | GenServer.call(__MODULE__, {:set_user, user}) 12 | end 13 | 14 | def handle_call({:set_user, user}, _, _state) do 15 | {:reply, :ok, user} 16 | end 17 | 18 | def handle_call(:get, _, user) do 19 | {:reply, user, user} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/Structs/Channels/news_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.NewsChannel do 2 | @moduledoc false 3 | alias Alchemy.OverWrite 4 | import Alchemy.Structs 5 | 6 | defstruct [ 7 | :id, 8 | :guild_id, 9 | :position, 10 | :permission_overwrites, 11 | :name, 12 | :topic, 13 | :nsfw, 14 | :last_message_id, 15 | :parent_id 16 | ] 17 | 18 | def from_map(map) do 19 | map 20 | |> field_map("permission_overwrites", &map_struct(&1, OverWrite)) 21 | |> to_struct(__MODULE__) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/Structs/Channels/voice_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.VoiceChannel do 2 | @moduledoc false 3 | alias Alchemy.OverWrite 4 | import Alchemy.Structs 5 | 6 | defstruct [ 7 | :id, 8 | :guild_id, 9 | :position, 10 | :permission_overwrites, 11 | :name, 12 | :nsfw, 13 | :bitrate, 14 | :user_limit, 15 | :parent_id 16 | ] 17 | 18 | def from_map(map) do 19 | map 20 | |> field_map("permission_overwrites", &map_struct(&1, OverWrite)) 21 | |> to_struct(__MODULE__) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/Structs/Channels/text_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.TextChannel do 2 | @moduledoc false 3 | alias Alchemy.OverWrite 4 | import Alchemy.Structs 5 | 6 | defstruct [ 7 | :id, 8 | :guild_id, 9 | :position, 10 | :permission_overwrites, 11 | :name, 12 | :topic, 13 | :nsfw, 14 | :last_message_id, 15 | :parent_id, 16 | :last_pin_timestamp 17 | ] 18 | 19 | def from_map(map) do 20 | map 21 | |> field_map("permission_overwrites", &map_struct(&1, OverWrite)) 22 | |> to_struct(__MODULE__) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | 11 | # Ignore .fetch files in case you like to edit your project deps locally. 12 | /.fetch 13 | 14 | # If the VM crashes, it generates a dump, let's ignore it too. 15 | erl_crash.dump 16 | 17 | # Also ignore archive artifacts (built via "mix archive.build"). 18 | *.ez 19 | t.exs 20 | lib/CogLib/ 21 | doc/ 22 | mix.lock 23 | ttb_last_config 24 | -------------------------------------------------------------------------------- /lib/Structs/Channels/Invite/invite.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.Invite do 2 | @moduledoc false 3 | alias Alchemy.Channel.Invite.{InviteChannel, InviteGuild} 4 | import Alchemy.Structs 5 | 6 | defstruct [ 7 | :code, 8 | :guild, 9 | :channel, 10 | :inviter, 11 | :uses, 12 | :max_uses, 13 | :max_age, 14 | :temporary, 15 | :created_at, 16 | :revoked 17 | ] 18 | 19 | def from_map(map) do 20 | map 21 | |> field("guild", InviteGuild) 22 | |> field("channel", InviteChannel) 23 | |> to_struct(__MODULE__) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/Structs/Channels/StageVoiceChannel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.StageVoiceChannel do 2 | @moduledoc false 3 | alias Alchemy.OverWrite 4 | import Alchemy.Structs 5 | 6 | defstruct [ 7 | :id, 8 | :guild_id, 9 | :position, 10 | :permission_overwrites, 11 | :name, 12 | :nsfw, 13 | :bitrate, 14 | :user_limit, 15 | :parent_id, 16 | :rtc_region, 17 | :topic, 18 | :video_quality_mode 19 | ] 20 | 21 | def from_map(map) do 22 | map 23 | |> field_map("permission_overwrites", &map_struct(&1, OverWrite)) 24 | |> to_struct(__MODULE__) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/Structs/Channels/store_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel.StoreChannel do 2 | @moduledoc false 3 | alias Alchemy.OverWrite 4 | import Alchemy.Structs 5 | 6 | # Note: should never encounter a store channel, as they're not something 7 | # bots can send/read to. It's "the store." 8 | 9 | defstruct [ 10 | :id, 11 | :guild_id, 12 | :position, 13 | :permission_overwrites, 14 | :name, 15 | :last_message_id, 16 | :parent_id 17 | ] 18 | 19 | def from_map(map) do 20 | map 21 | |> field_map("permission_overwrites", &map_struct(&1, OverWrite)) 22 | |> to_struct(__MODULE__) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/Structs/Guild/integration.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Guild.Integration do 2 | @moduledoc false 3 | alias Alchemy.User 4 | import Alchemy.Structs 5 | 6 | defstruct [ 7 | :id, 8 | :name, 9 | :type, 10 | :enabled, 11 | :syncing, 12 | :role_id, 13 | :expire_behaviour, 14 | :expire_grace_period, 15 | :user, 16 | :account, 17 | :synced_at 18 | ] 19 | 20 | defmodule Account do 21 | @moduledoc false 22 | defstruct [:id, :name] 23 | end 24 | 25 | def from_map(map) do 26 | map 27 | |> field("user", User) 28 | |> field("account", Account) 29 | |> to_struct(__MODULE__) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/Voice/udp.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Voice.UDP do 2 | @moduledoc false 3 | 4 | def open_udp(endpoint, port, ssrc) do 5 | {:ok, discord_ip} = :inet.parse_address(to_charlist(endpoint)) 6 | data = <> 7 | udp_opts = [:binary, active: false, reuseaddr: true] 8 | {:ok, udp} = :gen_udp.open(0, udp_opts) 9 | :gen_udp.send(udp, discord_ip, port, data) 10 | {:ok, discovery} = :gen_udp.recv(udp, 70) 11 | 12 | <<_padding::size(32), my_ip::bitstring-size(112), _null::size(400), my_port::size(16)>> = 13 | discovery |> Tuple.to_list() |> List.last() 14 | 15 | {my_ip, my_port, discord_ip, udp} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/Structs/Messages/message_reference.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.MessageReference do 2 | @moduledoc """ 3 | Represents a reply to a discord message. 4 | 5 | To reply with the bot to a message use it as following: 6 | 7 | ## Examples 8 | ```elixir 9 | m = %Alchemy.MessageReference{ 10 | # ID of the message you would like to reply to 11 | message_id: message_id, 12 | guild_id: guild_id 13 | } 14 | Client.send_message(channel_id, "Reply", message_reference: m) 15 | ``` 16 | """ 17 | 18 | @derive Poison.Encoder 19 | defstruct [ 20 | :message_id, 21 | :guild_id, 22 | channel_id: nil, 23 | fail_if_not_exists: true 24 | ] 25 | end 26 | -------------------------------------------------------------------------------- /lib/EventStage/event_dispatcher.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventStage.EventDispatcher do 2 | # Serves as a small consumer of the 3rd stage, 3 | @moduledoc false 4 | # forwarding events to notify processes subscribed in the EventRegistry 5 | use GenStage 6 | alias Alchemy.Cogs.EventRegistry 7 | alias Alchemy.EventStage.Cacher 8 | 9 | def start_link(limit) do 10 | GenStage.start_link(__MODULE__, limit) 11 | end 12 | 13 | def init(limit) do 14 | producers = for x <- 1..limit, do: Module.concat(Cacher, :"#{x}") 15 | {:consumer, :ok, subscribe_to: producers} 16 | end 17 | 18 | def handle_events(events, _from, state) do 19 | EventRegistry.dispatch(events) 20 | {:noreply, [], state} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/EventStage/tasker.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventStage.Tasker do 2 | # Serves as the final stage, recieving 3 | @moduledoc false 4 | # functions and commands to run in new tasks 5 | use ConsumerSupervisor 6 | alias Alchemy.EventStage.{CommandStage, EventStage} 7 | 8 | defmodule Runner do 9 | @moduledoc false 10 | def start_link({m, f, a}) do 11 | Task.start_link(m, f, a) 12 | end 13 | end 14 | 15 | def start_link do 16 | ConsumerSupervisor.start_link(__MODULE__, :ok) 17 | end 18 | 19 | def init(:ok) do 20 | children = [worker(Runner, [], restart: :temporary)] 21 | producers = [CommandStage, EventStage] 22 | {:ok, children, strategy: :one_for_one, subscribe_to: producers} 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/Cogs/event_registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cogs.EventRegistry do 2 | # Serves as a registry for processes wanting 3 | @moduledoc false 4 | # to subscribe to events. The dispatch will then be used to allow 5 | # for dynamic hooking into events. 6 | 7 | def start_link do 8 | Registry.start_link(keys: :duplicate, name: __MODULE__) 9 | end 10 | 11 | def subscribe do 12 | # the calling process will be sent in 13 | Registry.register(__MODULE__, :subscribed, nil) 14 | end 15 | 16 | def dispatch(events) do 17 | Registry.dispatch(__MODULE__, :subscribed, fn entries -> 18 | Enum.each(entries, fn {pid, _} -> 19 | Enum.each(events, &send(pid, {:discord_event, &1})) 20 | end) 21 | end) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/EventStage/eventstage.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventStage.EventStage do 2 | # Serves as the 2nd part of the 3rd stage 3 | @moduledoc false 4 | # Takes the events, and finds out which handler functions to call, 5 | # before sending them down to the last stage. 6 | use GenStage 7 | alias Alchemy.EventStage.Cacher 8 | alias Alchemy.Cogs.EventHandler 9 | 10 | def start_link(limit) do 11 | GenStage.start_link(__MODULE__, limit, name: __MODULE__) 12 | end 13 | 14 | def init(limit) do 15 | producers = for x <- 1..limit, do: Module.concat(Cacher, :"#{x}") 16 | {:producer_consumer, :ok, subscribe_to: producers} 17 | end 18 | 19 | def handle_events(events, _from, state) do 20 | found = EventHandler.find_handles(events) 21 | {:noreply, found, state} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/mix/tasks/alchemy/init.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Alchemy.Init do 2 | def run(_) do 3 | File.write("lib/mybot.ex", """ 4 | defmodule MyBot do 5 | use Application 6 | alias Alchemy.Client 7 | 8 | defmodule Commands do 9 | use Alchemy.Cogs 10 | 11 | Cogs.def ping do 12 | Cogs.say "pong!" 13 | end 14 | end 15 | 16 | def start(_type, _args) do 17 | run = Client.start("your token here") 18 | use Commands 19 | run 20 | end 21 | end 22 | """) 23 | 24 | IO.puts(""" 25 | An example bot has been generated in `lib/mybot.ex`. 26 | Next, add your token to line 14 of that file, and modify mix.exs to contain the following: 27 | def application do 28 | [mod: {MyBot, []}] 29 | end 30 | """) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request, push] 3 | jobs: 4 | mix_test: 5 | name: mix test (Elixir ${{ matrix.elixir }} OTP ${{ matrix.otp }}) 6 | runs-on: ubuntu-20.04 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | - elixir: 1.10.4 12 | otp: 21.3.8.16 13 | - elixir: 1.10.4 14 | otp: 23.0.3 15 | steps: 16 | - uses: actions/checkout@v2.3.2 17 | - uses: erlef/setup-elixir@v1 18 | with: 19 | otp-version: ${{ matrix.otp }} 20 | elixir-version: ${{ matrix.elixir }} 21 | - name: Install Dependencies 22 | run: | 23 | mix local.hex --force 24 | mix local.rebar --force 25 | mix deps.get --only test 26 | - name: Check formatting 27 | run: mix format --check-formatted 28 | - name: Run tests 29 | run: mix test 30 | -------------------------------------------------------------------------------- /lib/Cache/utility.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cache.Utility do 2 | # contains useful functions for working with maps 3 | @moduledoc false 4 | 5 | # Takes a list of maps, and returns a new map with the "id" of each map pointing 6 | # to the original 7 | # [%{"id" => 1, "f" => :foo}, %{"id" = 2, "f" => :foo}] => %{1 => ..., 2 =>} 8 | def index(map_list, key \\ ["id"]) do 9 | Enum.into(map_list, %{}, &{get_in(&1, key), &1}) 10 | end 11 | 12 | # Used to apply `index` to multiple nested fields in a struct 13 | def inner_index(base, inners) do 14 | List.foldr(inners, base, fn {field, path}, acc -> 15 | update_in(acc, field, &index(&1, path)) 16 | end) 17 | end 18 | 19 | # this will check for null keys 20 | def safe_inner_index(base, inners) do 21 | List.foldr(inners, base, fn {field, path}, acc -> 22 | case get_in(acc, field) do 23 | nil -> acc 24 | _ -> update_in(acc, field, &index(&1, path)) 25 | end 26 | end) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/EventStage/commandstage.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventStage.CommandStage do 2 | # One of the 2 parts of the third stage of the pipeline 3 | @moduledoc false 4 | # This serves to figure out which message create events 5 | # contain a command needing to be run, and then send those forward 6 | # to the final stage 7 | use GenStage 8 | alias Alchemy.EventStage.Cacher 9 | alias Alchemy.Cogs.CommandHandler 10 | 11 | def start_link(limit) do 12 | GenStage.start_link(__MODULE__, limit, name: __MODULE__) 13 | end 14 | 15 | def init(limit) do 16 | selector = fn {event, _args} -> event == :message_create end 17 | 18 | producers = 19 | for x <- 1..limit do 20 | {Module.concat(Cacher, :"#{x}"), selector: selector} 21 | end 22 | 23 | {:producer_consumer, :ok, subscribe_to: producers} 24 | end 25 | 26 | def handle_events(events, _from, state) do 27 | # most message creates get filtered out here 28 | found = CommandHandler.find_commands(events) 29 | {:noreply, found, state} 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) <2017> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/Voice/supervisor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Voice.SupervisorTest do 2 | use ExUnit.Case, async: true 3 | alias Alchemy.Voice.Supervisor, as: VoiceSupervisor 4 | 5 | setup do 6 | pid = 7 | case Process.whereis(VoiceSupervisor) do 8 | nil -> 9 | {:ok, pid} = VoiceSupervisor.start_link() 10 | pid 11 | 12 | pid -> 13 | pid 14 | end 15 | 16 | {:ok, supervisor: pid} 17 | end 18 | 19 | test "re-registering doesn't work", %{supervisor: supervisor} do 20 | assert GenServer.call(VoiceSupervisor.Server, {:start_client, 1}) == :ok 21 | bad_resp = GenServer.call(VoiceSupervisor.Server, {:start_client, 1}) 22 | refute bad_resp == :ok 23 | 24 | cleanup(supervisor) 25 | end 26 | 27 | test "different channels do work", %{supervisor: supervisor} do 28 | assert GenServer.call(VoiceSupervisor.Server, {:start_client, 3}) == :ok 29 | assert GenServer.call(VoiceSupervisor.Server, {:start_client, 4}) == :ok 30 | 31 | cleanup(supervisor) 32 | end 33 | 34 | defp cleanup(supervisor) do 35 | Supervisor.stop(supervisor) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/EventStage/stage_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventStage.StageSupervisor do 2 | @moduledoc false 3 | use Supervisor 4 | alias Alchemy.Cogs.{CommandHandler, EventHandler, EventRegistry} 5 | 6 | alias Alchemy.EventStage.{ 7 | Cacher, 8 | EventBuffer, 9 | EventDispatcher, 10 | CommandStage, 11 | EventStage, 12 | Tasker 13 | } 14 | 15 | def start_link(command_options) do 16 | Supervisor.start_link(__MODULE__, command_options, name: __MODULE__) 17 | end 18 | 19 | @limit System.schedulers_online() 20 | 21 | def init(command_options) do 22 | cogs = [ 23 | worker(CommandHandler, [command_options]), 24 | worker(EventHandler, []), 25 | worker(EventRegistry, []) 26 | ] 27 | 28 | stage1 = [worker(EventBuffer, [])] 29 | 30 | stage2 = 31 | for x <- 1..@limit do 32 | worker(Cacher, [x], id: x) 33 | end 34 | 35 | stage3_4 = [ 36 | worker(EventDispatcher, [@limit]), 37 | worker(CommandStage, [@limit]), 38 | worker(EventStage, [@limit]), 39 | worker(Tasker, []) 40 | ] 41 | 42 | children = cogs ++ stage1 ++ stage2 ++ stage3_4 43 | supervise(children, strategy: :one_for_one) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/Cache/channels.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cache.Channels do 2 | # Simply used to keep a map from channel_id => guild_id 3 | @moduledoc false 4 | use GenServer 5 | 6 | @type snowflake :: String.t() 7 | 8 | def start_link do 9 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 10 | end 11 | 12 | def init(:ok) do 13 | {:ok, :ets.new(:channels, [:named_table])} 14 | end 15 | 16 | # unavailability does not get checked when this gets triggered 17 | def add_channels(nil, _) do 18 | nil 19 | end 20 | 21 | def add_channels(channels, guild_id) do 22 | GenServer.call(__MODULE__, {:add, channels, guild_id}) 23 | end 24 | 25 | def remove_channel(id) do 26 | GenServer.call(__MODULE__, {:remove, id}) 27 | end 28 | 29 | @spec lookup(snowflake) :: {:ok, snowflake} | {:error, String.t()} 30 | def lookup(id) do 31 | GenServer.call(__MODULE__, {:lookup, id}) 32 | end 33 | 34 | def handle_call({:add, channels, guild_id}, _, table) do 35 | for channel <- channels do 36 | :ets.insert(table, {channel["id"], guild_id}) 37 | end 38 | 39 | {:reply, :ok, table} 40 | end 41 | 42 | def handle_call({:remove, id}, _, table) do 43 | :ets.delete(table, id) 44 | {:reply, :ok, table} 45 | end 46 | 47 | def handle_call({:lookup, channel_id}, _, table) do 48 | case :ets.lookup(table, channel_id) do 49 | [{_, guild_id}] -> {:reply, {:ok, guild_id}, table} 50 | [] -> {:reply, {:error, "Failed to find a channel entry for #{channel_id}."}, table} 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/Cache/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cache.Supervisor do 2 | @moduledoc false 3 | # This acts as the interface for the Cache. This module acts a GenServer, 4 | # with internal supervisors used to dynamically start small caches. 5 | # There are 4 major sections: 6 | # User; a GenServer keeping track of the state of the client. 7 | # Channels; a registry between channel ids, and the guild processes they belong to. 8 | # Guilds; A Supervisor spawning GenServers to keep the state of each guild, 9 | # as well as a GenServer keeping a registry of these children. 10 | # PrivateChannels; A Supervisor / GenServer combo, like Guilds, but with less info 11 | # stored. 12 | alias Alchemy.Cache.{Guilds, Guilds.GuildSupervisor, PrivChannels, User, Channels} 13 | use Supervisor 14 | 15 | def start_link do 16 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 17 | end 18 | 19 | def init(:ok) do 20 | children = [ 21 | supervisor(Registry, [:unique, :guilds], id: 1), 22 | supervisor(GuildSupervisor, []), 23 | worker(PrivChannels, []), 24 | worker(User, []), 25 | worker(Channels, []) 26 | ] 27 | 28 | supervise(children, strategy: :one_for_one) 29 | end 30 | 31 | # used to handle the READY event 32 | def ready(user, priv_channels, guilds) do 33 | # we pipe this into to_list to force evaluationd 34 | Task.async_stream(guilds, &Guilds.add_guild/1) 35 | |> Enum.to_list() 36 | 37 | PrivChannels.add_channels(priv_channels) 38 | User.set_user(user) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/Structs/structs.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Structs do 2 | @moduledoc false 3 | # Contains useful functions for working on the Structs in this library 4 | 5 | # Converts a map into a struct, handling string to atom conversion 6 | def to_struct(attrs, kind) do 7 | struct = struct(kind) 8 | 9 | Enum.reduce(Map.to_list(struct), struct, fn {k, _}, acc -> 10 | case Map.fetch(attrs, Atom.to_string(k)) do 11 | {:ok, v} -> %{acc | k => v} 12 | :error -> acc 13 | end 14 | end) 15 | end 16 | 17 | # Maps struct conversion over an enum 18 | def map_struct(nil, _), do: nil 19 | 20 | def map_struct(list, kind) do 21 | Enum.map(list, &to_struct(&1, kind)) 22 | end 23 | 24 | def field(map, key, kind) do 25 | update_in(map[key], &to_struct(&1, kind)) 26 | end 27 | 28 | def field?(map, key, kind) do 29 | case map[key] do 30 | nil -> map 31 | _ -> update_in(map[key], &to_struct(&1, kind)) 32 | end 33 | end 34 | 35 | def field_map(map, key, func) do 36 | update_in(map[key], &func.(&1)) 37 | end 38 | 39 | def field_map?(map, key, func) do 40 | case map[key] do 41 | nil -> map 42 | _ -> update_in(map, [key], &func.(&1)) 43 | end 44 | end 45 | 46 | def fields_from_map(map, key, module) do 47 | field_map(map, key, &Enum.map(&1, fn x -> module.from_map(x) end)) 48 | end 49 | 50 | def fields_from_map?(map, key, module) do 51 | case map[key] do 52 | nil -> map 53 | _ -> fields_from_map(map, key, module) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :alchemy, 7 | version: "0.6.9", 8 | elixir: "~> 1.8", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | description: description(), 12 | package: package(), 13 | deps: deps(), 14 | docs: docs() 15 | ] 16 | end 17 | 18 | def application do 19 | # Specify extra applications you'll use from Erlang/Elixir 20 | [extra_applications: [:logger]] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:httpoison, "~> 1.8"}, 26 | {:earmark, "~> 1.3", only: :dev}, 27 | {:websocket_client, "~> 1.3"}, 28 | {:ex_doc, "~> 0.20", only: :dev}, 29 | {:poison, "~> 4.0"}, 30 | {:gen_stage, "~> 0.14"}, 31 | {:porcelain, "~> 2.0"}, 32 | {:kcl, "~> 1.1"} 33 | ] 34 | end 35 | 36 | defp description do 37 | """ 38 | A Discord wrapper / framework for elixir. 39 | 40 | This package intends to provide a solid foundation for interacting 41 | with the Discord API, as well as a very easy command and event hook system. 42 | """ 43 | end 44 | 45 | defp docs do 46 | [main: "intro", extras: ["docs/Intro.md"]] 47 | end 48 | 49 | defp package do 50 | [ 51 | name: :discord_alchemy, 52 | files: ["lib", "mix.exs", "README.md", "LICENSE.md"], 53 | maintainers: ["Lúcás Meier"], 54 | licenses: ["MIT"], 55 | links: %{"GitHub" => "https://github.com/cronokirby/alchemy"} 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/Discord/Endpoints/users.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Users do 2 | @moduledoc false 3 | alias Alchemy.Discord.Api 4 | alias Alchemy.{Channel.DMChannel, User, UserGuild} 5 | 6 | @root "https://discord.com/api/v6/users/" 7 | 8 | # Returns a User struct, passing "@me" gets info for the current Client instead 9 | # Token is the first arg so that it can be prepended generically 10 | def get_user(token, client_id) do 11 | Api.get(@root <> client_id, token, %User{}) 12 | end 13 | 14 | # Modify the client's user account settings. 15 | def modify_user(token, options) do 16 | {_, options} = 17 | options 18 | |> Keyword.get_and_update(:avatar, fn 19 | nil -> :pop 20 | some -> {some, Api.fetch_avatar(some)} 21 | end) 22 | 23 | Api.patch(@root <> "@me", token, Api.encode(options), %User{}) 24 | end 25 | 26 | # Returns a list of %UserGuilds the current user is a member of. 27 | def get_current_guilds(token) do 28 | (@root <> "@me/guilds") 29 | |> Api.get(token, [%UserGuild{}]) 30 | end 31 | 32 | # Removes a client from a guild 33 | def leave_guild(token, guild_id) do 34 | (@root <> "@me/guilds/" <> guild_id) 35 | |> Api.delete(token) 36 | end 37 | 38 | # Gets a list of DMChannel objects for a user 39 | def get_DMs(token) do 40 | (@root <> "@me/channels") 41 | |> Api.get(token, Api.parse_map(DMChannel)) 42 | end 43 | 44 | def create_DM(token, user_id) do 45 | json = ~s/{"recipient_id": #{user_id}}/ 46 | 47 | (@root <> "@me/channels") 48 | |> Api.post(token, json, DMChannel) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/EventStage/event_buffer.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventStage.EventBuffer do 2 | # This is the entry point for the event pipeline 3 | @moduledoc false 4 | # The websockets notify this module of events, and this 5 | # stage buffers them until the handlers are ready. 6 | use GenStage 7 | 8 | def start_link do 9 | GenStage.start_link(__MODULE__, :ok, name: __MODULE__) 10 | end 11 | 12 | # Asyncronous, as it's imperative the WS doesn't get blocked. 13 | def notify(event) do 14 | GenStage.cast(__MODULE__, {:notify, event}) 15 | end 16 | 17 | def init(:ok) do 18 | {:producer, {:queue.new(), 0}} 19 | end 20 | 21 | def handle_cast({:notify, event}, {queue, demand}) do 22 | queue = :queue.in(event, queue) 23 | dispatch(queue, demand, []) 24 | end 25 | 26 | def handle_demand(incoming, {queue, pending}) do 27 | dispatch(queue, incoming + pending, []) 28 | end 29 | 30 | # This is the escape clause for the lower case, 31 | # but also the case that gets matched when there's no demand 32 | def dispatch(queue, 0, events) do 33 | {:noreply, Enum.reverse(events), {queue, 0}} 34 | end 35 | 36 | def dispatch(queue, demand, events) do 37 | # This recursion will end in 2 ways: 38 | # events < demand: we end up here, reverse events 39 | # because we were prepending them; and then dispatch 40 | # events > demand: the upper clause gets matched 41 | case :queue.out(queue) do 42 | {{:value, event}, queue} -> 43 | dispatch(queue, demand - 1, [event | events]) 44 | 45 | {:empty, queue} -> 46 | {:noreply, Enum.reverse(events), {queue, demand}} 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/Discord/Endpoints/webhooks.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Webhooks do 2 | @moduledoc false 3 | alias Alchemy.Discord.Api 4 | alias Alchemy.Webhook 5 | 6 | @root "https://discord.com/api/v6/" 7 | 8 | def create_webhook(token, channel_id, name, options) do 9 | options = 10 | case options do 11 | [] -> 12 | [name: name] 13 | 14 | [avatar: url] -> 15 | [name: name, avatar: Api.fetch_avatar(url)] 16 | end 17 | |> Api.encode() 18 | 19 | (@root <> "channels/" <> channel_id <> "/webhooks") 20 | |> Api.post(token, options, %Webhook{}) 21 | end 22 | 23 | def channel_webhooks(token, channel_id) do 24 | (@root <> "channels/" <> channel_id <> "/webhooks") 25 | |> Api.get(token, [%Webhook{}]) 26 | end 27 | 28 | def guild_webhooks(token, guild_id) do 29 | (@root <> "guilds/" <> guild_id <> "/webhooks") 30 | |> Api.get(token, [%Webhook{}]) 31 | end 32 | 33 | def modify_webhook(token, id, wh_token, options) do 34 | options = 35 | case options do 36 | [{:avatar, url} | rest] -> 37 | [{:avatar, Api.fetch_avatar(url)} | rest] 38 | 39 | other -> 40 | other 41 | end 42 | |> Api.encode() 43 | 44 | (@root <> "/webhooks/" <> id <> "/" <> wh_token) 45 | |> Api.patch(token, options, %Webhook{}) 46 | end 47 | 48 | def delete_webhook(token, id, wh_token) do 49 | (@root <> "/webhooks/" <> id <> "/" <> wh_token) 50 | |> Api.delete(token) 51 | end 52 | 53 | def execute_webhook(token, id, wh_token, options) do 54 | (@root <> "/webhooks/" <> id <> "/" <> wh_token) 55 | |> Api.post(token, Api.encode(options)) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/Cache/priv_channels.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cache.PrivChannels do 2 | # This genserver keeps an internal ets table of private channels, 3 | @moduledoc false 4 | # thus serving as the cache for them 5 | # This also keeps a mapping from recipient -> channel id 6 | use GenServer 7 | 8 | def start_link do 9 | GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 10 | end 11 | 12 | def init(:ok) do 13 | table = :ets.new(:priv_channels, [:named_table]) 14 | {:ok, table} 15 | end 16 | 17 | def add_channel(channel) do 18 | GenServer.call(__MODULE__, {:add, channel}) 19 | end 20 | 21 | # this is mainly used in the ready event 22 | def add_channels(channels) do 23 | GenServer.call(__MODULE__, {:add_list, channels}) 24 | end 25 | 26 | # Because we're using a set based table, inserting the entry will overwrite. 27 | def update_channel(channel) do 28 | GenServer.call(__MODULE__, {:add, channel}) 29 | end 30 | 31 | def remove_channel(channel) do 32 | GenServer.call(__MODULE__, {:delete, channel}) 33 | end 34 | 35 | def handle_call({:add, channel}, _from, table) do 36 | %{"id" => id, "recipients" => [%{"id" => user_id} | _]} = channel 37 | :ets.insert(table, {id, channel}) 38 | :ets.insert(table, {user_id, id}) 39 | {:reply, :ok, table} 40 | end 41 | 42 | def handle_call({:add_list, channels}, _from, table) do 43 | Enum.each(channels, fn %{"id" => id, "recipients" => [%{"id" => user_id} | _]} = c -> 44 | :ets.insert(table, {id, c}) 45 | :ets.insert(table, {user_id, id}) 46 | end) 47 | 48 | {:reply, :ok, table} 49 | end 50 | 51 | def handle_call({:delete, channel}, _from, table) do 52 | :ets.delete(table, channel["id"]) 53 | {:reply, :ok, table} 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/Structs/Guild/guild_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AlchemyTest.Structs.Guild.GuildTest do 2 | use ExUnit.Case, async: true 3 | alias Alchemy.Guild 4 | 5 | setup do 6 | without_icon = %{ 7 | "id" => "42", 8 | "name" => "test guild", 9 | "icon" => nil, 10 | "splash" => nil, 11 | "owner_id" => 20, 12 | "permissions" => 0, 13 | "region" => "unit test land", 14 | "afk_channel_id" => nil, 15 | "afk_timeout" => 0, 16 | "verification_level" => 0, 17 | "default_message_notifications" => 0, 18 | "roles" => [], 19 | "emojis" => [], 20 | "features" => [], 21 | "mfa_level" => 0 22 | } 23 | 24 | with_icon = Map.put(without_icon, "icon", "ababababa") 25 | 26 | %{ 27 | guild_without_icon: Guild.from_map(without_icon), 28 | guild_with_icon: Guild.from_map(with_icon) 29 | } 30 | end 31 | 32 | test "`icon_url` for guild without icon hash is `nil`", %{guild_without_icon: guild} do 33 | assert Guild.icon_url(guild) == nil 34 | end 35 | 36 | test "icon type and size are configurable", %{guild_with_icon: guild} do 37 | assert String.contains?(Guild.icon_url(guild, "jpeg"), ".jpeg") 38 | assert String.ends_with?(Guild.icon_url(guild, "jpg", 1024), "1024") 39 | end 40 | 41 | test "`icon_url` for guild with icon hash is a string", %{guild_with_icon: guild} do 42 | assert is_bitstring(Guild.icon_url(guild)) 43 | end 44 | 45 | test "`icon_url` for invalid params raises `ArgumentError`", %{guild_with_icon: guild} do 46 | assert_raise ArgumentError, fn -> Guild.icon_url(guild, 42) end 47 | assert_raise ArgumentError, fn -> Guild.icon_url(guild, "json") end 48 | assert_raise ArgumentError, fn -> Guild.icon_url(guild, 42, 0) end 49 | assert_raise ArgumentError, fn -> Guild.icon_url(guild, "abc", 51) end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/Discord/rate_limits.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.RateLimits do 2 | @moduledoc false 3 | # Used for parsing ratelimits out of headers 4 | require Logger 5 | 6 | defmodule RateInfo do 7 | @moduledoc false 8 | defstruct [:limit, :remaining, :reset_time] 9 | end 10 | 11 | # will only match if the ratelimits are present 12 | defp parse_headers(%{"x-ratelimit-remaining" => remaining} = headers) do 13 | {remaining, _} = Integer.parse(remaining) 14 | {reset_time, _} = Float.parse(headers["x-ratelimit-reset"]) 15 | {limit, _} = Integer.parse(headers["x-ratelimit-limit"]) 16 | %RateInfo{limit: limit, remaining: remaining, reset_time: reset_time} 17 | end 18 | 19 | defp parse_headers(_none) do 20 | nil 21 | end 22 | 23 | # status code empty 24 | def rate_info(%{status_code: 204}) do 25 | nil 26 | end 27 | 28 | def rate_info(%{status_code: status_code, headers: h}) when status_code in [200, 201] do 29 | h |> Enum.into(%{}) |> parse_headers 30 | end 31 | 32 | # Used in the case of a 429 error, expected to "decide" what response to give 33 | def rate_info(%{status_code: 429, headers: h, body: body}) do 34 | body = Poison.Parser.parse!(body, %{}) 35 | timeout = body["retry_after"] 36 | 37 | if body["global"] do 38 | {:global, timeout} 39 | else 40 | {:local, timeout, h |> Enum.into(%{}) |> parse_headers} 41 | end 42 | end 43 | 44 | # Used the first time a bucket is accessed during the program 45 | # It makes it so that in the case of multiple processes getting sent at the same time 46 | # to a virgin bucket, they'll have to wait for the first one to clear through, 47 | # and get rate info. 48 | def default_info do 49 | now = DateTime.utc_now() |> DateTime.to_unix() 50 | # 2 seconds should be enough to let the first one get a clean request 51 | %RateInfo{limit: 1, remaining: 1, reset_time: now + 2} 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/Cogs/event_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cogs.EventHandler do 2 | # This server keeps tracks of the various handler 3 | @moduledoc false 4 | # functions subscribed to different events. The EventStage uses 5 | # this server to figure out how to dispatch commands 6 | use GenServer 7 | 8 | def disable(module, function) do 9 | GenServer.call(__MODULE__, {:disable, module, function}) 10 | end 11 | 12 | def unload(module) do 13 | GenServer.call(__MODULE__, {:unload, module}) 14 | end 15 | 16 | # Used at the beginning of the application to add said handles 17 | def add_handler(handle) do 18 | GenServer.call(__MODULE__, {:add_handle, handle}) 19 | end 20 | 21 | def find_handles(events) do 22 | state = GenServer.call(__MODULE__, :copy) 23 | 24 | Enum.flat_map(events, fn {type, args} -> 25 | case state[type] do 26 | nil -> 27 | [] 28 | 29 | handles -> 30 | Enum.map(handles, fn {m, f} -> {m, f, args} end) 31 | end 32 | end) 33 | end 34 | 35 | ### Server ### 36 | 37 | def start_link do 38 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 39 | end 40 | 41 | def handle_call({:disable, module, function}, _from, state) do 42 | new = 43 | Enum.map(state, fn {k, v} -> 44 | {k, Enum.filter(v, &(!match?({^module, ^function}, &1)))} 45 | end) 46 | |> Enum.into(%{}) 47 | 48 | {:reply, :ok, new} 49 | end 50 | 51 | def handle_call({:unload, module}, _from, state) do 52 | new = 53 | Enum.map(state, fn {k, v} -> 54 | {k, Enum.filter(v, &(!match?({^module, _}, &1)))} 55 | end) 56 | |> Enum.into(%{}) 57 | 58 | {:reply, :ok, new} 59 | end 60 | 61 | # Adds a new handler to the map, indexed by type 62 | def handle_call({:add_handle, {type, handle}}, _from, state) do 63 | {:reply, :ok, 64 | update_in(state[type], fn maybe -> 65 | case maybe do 66 | # nil because the type doesn't have a func yet 67 | nil -> [handle] 68 | val -> [handle | val] 69 | end 70 | end)} 71 | end 72 | 73 | def handle_call(:copy, _from, state) do 74 | {:reply, state, state} 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/EventStage/cacher.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.EventStage.Cacher do 2 | # This stage serves to update the cache 3 | @moduledoc false 4 | # before passing events on. 5 | # To leverage the concurrent cache, this module 6 | # is intended to be duplicated for each scheduler. 7 | # After that, it broadcasts split over the command and event dispatcher 8 | use GenStage 9 | require Logger 10 | alias Alchemy.EventStage.EventBuffer 11 | alias Alchemy.Discord.Events 12 | 13 | # Each of the instances gets a specific id 14 | def start_link(id) do 15 | name = Module.concat(__MODULE__, :"#{id}") 16 | GenStage.start_link(__MODULE__, :ok, name: name) 17 | end 18 | 19 | def init(:ok) do 20 | # no state to keep track of, subscribe to the event source 21 | {:producer_consumer, :ok, 22 | [subscribe_to: [EventBuffer], dispatcher: GenStage.BroadcastDispatcher]} 23 | end 24 | 25 | def handle_events(events, _from, state) do 26 | # I think that using async_stream here would be redundant, 27 | # as we're already duplicating this stage. This might warrant future 28 | # testing, and would be an easy change to implement 29 | cached = 30 | Enum.map(events, fn {type, payload} -> 31 | handle_event(type, payload) 32 | end) 33 | 34 | {:noreply, cached, state} 35 | end 36 | 37 | defp handle_event(type, %{"guild_id" => guild_id} = payload) when is_binary(guild_id) do 38 | # This is to handle possible calls for a guild 39 | # which has not been registered yet. 40 | # 41 | # This can happen when a bot joins a guild 42 | # and at the same time any member gets updated - new username, new role assigned etc. 43 | # 44 | # We will receive the signals GUILD_MEMBER_UPDATE & GUILD_CREATE simultaneously. 45 | # 46 | # If the GUILD_MEMBER_UPDATE signal gets processed before the GUILD_CREATE 47 | # the Cache will crash as no genserver with the given guild id exists in the 48 | # Registry yet. 49 | if Registry.lookup(:guilds, guild_id) != [] do 50 | Events.handle(type, payload) 51 | else 52 | Logger.debug( 53 | "Not handling #{inspect(type)} for #{guild_id} as the guild has not been started yet. Payload was #{ 54 | inspect(payload) 55 | }" 56 | ) 57 | 58 | {:unkown, []} 59 | end 60 | end 61 | 62 | defp handle_event(type, payload) do 63 | Events.handle(type, payload) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/Discord/Gateway/payloads.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Payloads do 2 | @moduledoc false 3 | # These contain functions that construct payloads. 4 | # For deconstruction, see Alchemy.Discord.Events 5 | 6 | def opcode(op) do 7 | %{ 8 | dispatch: 0, 9 | heartbeat: 1, 10 | identify: 2, 11 | status_update: 3, 12 | voice_update: 4, 13 | voice_ping: 5, 14 | resume: 6, 15 | reconnect: 7, 16 | req_guild_members: 8, 17 | invalid: 9, 18 | hello: 10, 19 | ACK: 11 20 | }[op] 21 | end 22 | 23 | # Constructs a sendable payload string, from an opcode, and data, in map form 24 | def build_payload(op, data) do 25 | payload = %{op: opcode(op), d: data} 26 | Poison.encode!(payload) 27 | end 28 | 29 | def properties(os) do 30 | %{ 31 | "$os" => os, 32 | "$browser" => "alchemy", 33 | "$device" => "alchemy", 34 | "$referrer" => "", 35 | "$referring_domain" => "" 36 | } 37 | end 38 | 39 | def identify_msg(token, shard) do 40 | {os, _} = :os.type() 41 | 42 | identify = %{ 43 | token: token, 44 | properties: properties(os), 45 | compress: true, 46 | large_threshold: 250, 47 | shard: shard 48 | } 49 | 50 | build_payload(:identify, identify) 51 | end 52 | 53 | def resume_msg(state) do 54 | resume = %{token: state.token, session_id: state.session_id, seq: state.seq} 55 | build_payload(:resume, resume) 56 | end 57 | 58 | def heartbeat(seq) do 59 | build_payload(:heartbeat, seq) 60 | end 61 | 62 | def status_update(idle_since, info) do 63 | game = 64 | case info do 65 | nil -> 66 | nil 67 | 68 | {:streaming, game_name, twitch} -> 69 | %{name: game_name, type: 1, url: "https://twitch.tv/" <> twitch} 70 | 71 | {:playing, game_name} -> 72 | %{name: game_name, type: 0} 73 | end 74 | 75 | payload = %{since: idle_since, game: game} 76 | build_payload(:status_update, payload) 77 | end 78 | 79 | def request_guild_members(guild_id, username, limit) do 80 | payload = %{guild_id: guild_id, query: username, limit: limit} 81 | build_payload(:req_guild_members, payload) 82 | end 83 | 84 | def voice_update(guild_id, channel_id, mute, deaf) do 85 | payload = %{guild_id: guild_id, channel_id: channel_id, self_mute: mute, self_deaf: deaf} 86 | build_payload(:voice_update, payload) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/Discord/Gateway/gateway.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Gateway do 2 | @moduledoc false 3 | @behaviour :websocket_client 4 | alias Alchemy.Discord.Gateway.Manager 5 | import Alchemy.Discord.Payloads 6 | import Alchemy.Discord.Protocol 7 | require Logger 8 | 9 | defmodule State do 10 | @moduledoc false 11 | defstruct [:token, :shard, :trace, :session_id, :seq, :user_id] 12 | end 13 | 14 | # Requests a gateway URL, before then connecting, and storing the token 15 | def start_link(token, shard) do 16 | :crypto.start() 17 | :ssl.start() 18 | 19 | # request_url will return a protocol to execute 20 | # which either returns the url or another protocol 21 | # to execute. 22 | url = Manager.request_url().() |> get_url 23 | Logger.info("Shard #{inspect(shard)} connecting to the gateway") 24 | 25 | :websocket_client.start_link(url, __MODULE__, %State{token: token, shard: shard}) 26 | end 27 | 28 | def get_url(s) when is_binary(s), do: s 29 | def get_url(f), do: get_url(f.()) 30 | 31 | def init(state) do 32 | {:once, state} 33 | end 34 | 35 | def onconnect(_ws_req, state) do 36 | {:ok, state} 37 | end 38 | 39 | def ondisconnect(_reason, state) do 40 | {:reconnect, state} 41 | end 42 | 43 | # Messages are either raw, or compressed JSON 44 | def websocket_handle({:binary, msg}, _conn_state, state) do 45 | msg |> :zlib.uncompress() |> (fn x -> Poison.Parser.parse!(x, %{}) end).() |> dispatch(state) 46 | end 47 | 48 | def websocket_handle({:text, msg}, _conn_state, state) do 49 | msg |> (fn x -> Poison.Parser.parse!(x, %{}) end).() |> dispatch(state) 50 | end 51 | 52 | # Heartbeats need to be sent every interval 53 | def websocket_info({:heartbeat, interval}, _conn_state, state) do 54 | Process.send_after(self(), {:heartbeat, interval}, interval) 55 | {:reply, {:text, heartbeat(state.seq)}, state} 56 | end 57 | 58 | # Send the identify package to discord, if this is our fist session 59 | def websocket_info(:identify, _, %State{session_id: nil} = state) do 60 | this_shard = state.shard 61 | identify = identify_msg(state.token, this_shard) 62 | Process.send_after(GatewayManager, {:next_shard, this_shard}, 5000) 63 | {:reply, {:text, identify}, state} 64 | end 65 | 66 | # We can resume if we already have a session_id (i.e. we disconnected) 67 | def websocket_info(:identify, _, state) do 68 | {:reply, {:text, resume_msg(state)}, state} 69 | end 70 | 71 | # RateLimiting has been handled prior 72 | def websocket_info({:send_event, data}, _, state) do 73 | {:reply, {:text, data}, state} 74 | end 75 | 76 | def websocket_terminate(why, _conn_state, state) do 77 | Logger.debug("Shard #{inspect(state.shard)} terminated, reason: #{inspect(why)}") 78 | :ok 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/Discord/Gateway/protocol.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Protocol do 2 | @moduledoc false 3 | require Logger 4 | alias Alchemy.EventStage.EventBuffer 5 | alias Alchemy.Voice.Supervisor.Server 6 | alias Alchemy.Cache.Supervisor, as: Cache 7 | import Alchemy.Discord.Payloads 8 | 9 | # Immediate heartbeat request 10 | def dispatch(%{"op" => 1}, state) do 11 | {:reply, {:text, heartbeat(state.seq)}, state} 12 | end 13 | 14 | # Disconnection warning 15 | def dispatch(%{"op" => 7}, state) do 16 | Logger.debug( 17 | "Shard " <> 18 | inspect(state.shard) <> 19 | " Disconnected from the Gateway; restarting the Gateway" 20 | ) 21 | end 22 | 23 | # Invalid session_id. This is quite fatal. 24 | def dispatch(%{"op" => 9}, state) do 25 | Logger.debug( 26 | "Shard #{inspect(state.shard)} " <> 27 | "connected with an invalid session id" 28 | ) 29 | 30 | Process.exit(self(), :brutal_kill) 31 | end 32 | 33 | # Heartbeat payload, defining the interval to beat to 34 | def dispatch(%{"op" => 10, "d" => payload}, state) do 35 | interval = payload["heartbeat_interval"] 36 | send(self(), :identify) 37 | Process.send_after(self(), {:heartbeat, interval}, interval) 38 | {:ok, %{state | trace: payload["_trace"]}} 39 | end 40 | 41 | # Heartbeat ACK, doesn't do anything noteworthy 42 | def dispatch(%{"op" => 11}, state) do 43 | {:ok, state} 44 | end 45 | 46 | # The READY event, part of the standard protocol 47 | def dispatch(%{"t" => "READY", "s" => seq, "d" => payload}, state) do 48 | Cache.ready( 49 | payload["user"], 50 | payload["private_channels"], 51 | payload["guilds"] 52 | ) 53 | 54 | EventBuffer.notify({"READY", Map.put(payload, "shard", state.shard)}) 55 | Logger.debug("Shard #{inspect(state.shard)} received READY") 56 | 57 | {:ok, 58 | %{ 59 | state 60 | | seq: seq, 61 | session_id: payload["session_id"], 62 | trace: payload["_trace"], 63 | user_id: payload["user"]["id"] 64 | }} 65 | end 66 | 67 | # Sent after resuming to the gateway 68 | def dispatch(%{"t" => "RESUMED", "d" => payload}, state) do 69 | Logger.debug("Shard #{inspect(state.shard)} resumed gateway connection") 70 | {:ok, %{state | trace: payload["_trace"]}} 71 | end 72 | 73 | def dispatch(%{"t" => "VOICE_SERVER_UPDATE", "d" => payload, "s" => seq}, state) do 74 | Server.send_to(payload["guild_id"], {payload["token"], payload["endpoint"]}) 75 | {:ok, %{state | seq: seq}} 76 | end 77 | 78 | def dispatch( 79 | %{"t" => "VOICE_STATE_UPDATE", "s" => seq, "d" => %{"user_id" => u} = payload}, 80 | %{user_id: u} = state 81 | ) do 82 | Server.send_to(payload["guild_id"], {u, payload["session_id"]}) 83 | EventBuffer.notify({"VOICE_STATE_UPDATE", payload}) 84 | {:ok, %{state | seq: seq}} 85 | end 86 | 87 | def dispatch(%{"t" => type, "d" => payload, "s" => seq}, state) do 88 | EventBuffer.notify({type, payload}) 89 | {:ok, %{state | seq: seq}} 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/Cogs/command_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cogs.CommandHandler do 2 | @moduledoc false 3 | require Logger 4 | use GenServer 5 | 6 | def add_commands(module, commands) do 7 | GenServer.call(__MODULE__, {:add_commands, module, commands}) 8 | end 9 | 10 | def set_prefix(new) do 11 | GenServer.call(__MODULE__, {:set_prefix, new}) 12 | end 13 | 14 | def unload(module) do 15 | GenServer.call(__MODULE__, {:unload, module}) 16 | end 17 | 18 | def disable(func) do 19 | GenServer.call(__MODULE__, {:disable, func}) 20 | end 21 | 22 | # Filters through a list of messages, trying to find a command 23 | def find_commands(events) do 24 | state = GenServer.call(__MODULE__, :copy) 25 | 26 | predicate = 27 | case state.options do 28 | [{:selfbot, id} | _] -> 29 | &(&1.author.id == id && 30 | String.starts_with?(&1.content, state.prefix)) 31 | 32 | _ -> 33 | &String.starts_with?(&1.content, state.prefix) 34 | end 35 | 36 | events 37 | |> Stream.filter(fn {_type, [message]} -> 38 | predicate.(message) 39 | end) 40 | |> Stream.map(fn {_type, [message]} -> 41 | get_command(message, state) 42 | end) 43 | |> Enum.filter(&(&1 != nil)) 44 | end 45 | 46 | defp get_command(message, state) do 47 | prefix = state.prefix 48 | 49 | destructure( 50 | [_, command, rest], 51 | message.content 52 | |> String.split([prefix, " "], parts: 3) 53 | |> Enum.concat(["", ""]) 54 | ) 55 | 56 | case state[command] do 57 | {mod, arity, method} -> 58 | command_tuple(mod, method, arity, &String.split/1, message, rest) 59 | 60 | {mod, arity, method, parser} -> 61 | command_tuple(mod, method, arity, parser, message, rest) 62 | 63 | _ -> 64 | nil 65 | end 66 | end 67 | 68 | # Returns information about the command, ready to be run 69 | defp command_tuple(mod, method, arity, parser, message, content) do 70 | args = Enum.take(parser.(content), arity) 71 | {mod, method, [message | args]} 72 | end 73 | 74 | ### Server ### 75 | 76 | def start_link(options) do 77 | # String keys to avoid conflict with functions 78 | GenServer.start_link(__MODULE__, %{prefix: "!", options: options}, name: __MODULE__) 79 | end 80 | 81 | def handle_call(:list, _from, state) do 82 | {:reply, state, state} 83 | end 84 | 85 | def handle_call({:unload, module}, _from, state) do 86 | new = 87 | Stream.filter(state, fn 88 | {_k, {^module, _, _}} -> false 89 | {_k, {^module, _}} -> false 90 | _ -> true 91 | end) 92 | |> Enum.into(%{}) 93 | 94 | {:reply, :ok, new} 95 | end 96 | 97 | def handle_call({:disable, func}, _from, state) do 98 | {_pop, new} = Map.pop(state, func) 99 | {:reply, :ok, new} 100 | end 101 | 102 | def handle_call({:set_prefix, prefix}, _from, state) do 103 | {:reply, :ok, %{state | prefix: prefix}} 104 | end 105 | 106 | def handle_call({:add_commands, module, commands}, _from, state) do 107 | Logger.info("*#{inspect(module)}* loaded as a command cog") 108 | {:reply, :ok, Map.merge(state, commands)} 109 | end 110 | 111 | def handle_call(:copy, _from, state) do 112 | {:reply, state, state} 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/Voice/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Voice.Supervisor do 2 | @moduledoc false 3 | # Supervises the voice section, including a registry and the dynamic 4 | # voice client supervisor. 5 | use Supervisor 6 | alias Alchemy.Discord.Gateway.RateLimiter 7 | alias Alchemy.Voice.Supervisor.Gateway 8 | require Logger 9 | 10 | alias __MODULE__.Server 11 | 12 | def start_link do 13 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 14 | end 15 | 16 | defmodule Gateway do 17 | @moduledoc false 18 | use Supervisor 19 | 20 | def start_link do 21 | Supervisor.start_link(__MODULE__, :ok, name: Gateway) 22 | end 23 | 24 | def init(:ok) do 25 | children = [ 26 | worker(Alchemy.Voice.Gateway, []) 27 | ] 28 | 29 | supervise(children, strategy: :simple_one_for_one) 30 | end 31 | end 32 | 33 | def init(:ok) do 34 | children = [ 35 | supervisor(Registry, [:unique, Registry.Voice]), 36 | supervisor(Alchemy.Voice.Supervisor.Gateway, []), 37 | worker(__MODULE__.Server, []) 38 | ] 39 | 40 | supervise(children, strategy: :one_for_one) 41 | end 42 | 43 | defmodule VoiceRegistry do 44 | @moduledoc false 45 | def via(key) do 46 | {:via, Registry, {Registry.Voice, key}} 47 | end 48 | end 49 | 50 | defmodule Server do 51 | @moduledoc false 52 | use GenServer 53 | 54 | def start_link do 55 | GenServer.start_link(__MODULE__, %{}, name: __MODULE__) 56 | end 57 | 58 | def send_to(guild, data) do 59 | GenServer.cast(__MODULE__, {:send_to, guild, data}) 60 | end 61 | 62 | def handle_call({:start_client, guild}, {pid, _}, state) do 63 | case Map.get(state, guild) do 64 | nil -> 65 | {:reply, :ok, Map.put(state, guild, pid)} 66 | 67 | _ -> 68 | {:reply, {:error, "Already joining this guild"}, state} 69 | end 70 | end 71 | 72 | def handle_call({:client_done, guild}, _, state) do 73 | {:reply, :ok, Map.delete(state, guild)} 74 | end 75 | 76 | def handle_cast({:send_to, guild, data}, state) do 77 | case Map.get(state, guild) do 78 | nil -> nil 79 | pid -> send(pid, data) 80 | end 81 | 82 | {:noreply, state} 83 | end 84 | end 85 | 86 | def start_client(guild, channel, timeout) do 87 | r = 88 | with :ok <- GenServer.call(Server, {:start_client, guild}), 89 | [] <- Registry.lookup(Registry.Voice, {guild, :gateway}) do 90 | RateLimiter.change_voice_state(guild, channel) 91 | 92 | recv = fn -> 93 | receive do 94 | x -> {:ok, x} 95 | after 96 | div(timeout, 2) -> {:error, "Timed out"} 97 | end 98 | end 99 | 100 | with {:ok, {user_id, session}} <- recv.(), 101 | {:ok, {token, url}} <- recv.(), 102 | {:ok, _pid1} <- 103 | Supervisor.start_child( 104 | Gateway, 105 | [url, token, session, user_id, guild, channel] 106 | ), 107 | {:ok, _pid2} <- recv.() do 108 | :ok 109 | end 110 | else 111 | [{_pid, _} | _] -> 112 | RateLimiter.change_voice_state(guild, channel) 113 | :ok 114 | end 115 | 116 | GenServer.call(Server, {:client_done, guild}) 117 | r 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/Structs/Users/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.User do 2 | @moduledoc """ 3 | This module contains functions and types related to discord users. 4 | """ 5 | alias Alchemy.UserGuild 6 | use Alchemy.Discord.Types 7 | 8 | @typedoc """ 9 | Represents a discord User. The default values exist to cover missing fields. 10 | 11 | - `id` 12 | 13 | represents a unique user id 14 | - `username` 15 | 16 | represents a user's current username 17 | - `discriminator` 18 | 19 | 4 digit tag to differenciate usernames 20 | - `avatar` 21 | 22 | A string representing their avatar hash. Use `avatar_url` to 23 | get the corresponding url from a `User` object 24 | - `bot` 25 | 26 | Whether or not the user is a bot - *default: `false`* 27 | 28 | A bot usually doesn't have the authorization necessary to access these 2, so 29 | they're usually missing. 30 | - `verified` 31 | 32 | Whether the account is verified - *default: `:hidden`* 33 | - `email` 34 | 35 | The user's email - *default: `:hidden`* 36 | """ 37 | @type t :: %__MODULE__{ 38 | id: String.t(), 39 | username: String.t(), 40 | discriminator: String.t(), 41 | avatar: String.t(), 42 | bot: Boolean, 43 | verified: :hidden | Boolean, 44 | email: :hidden | String.t() 45 | } 46 | @derive [Poison.Encoder] 47 | defstruct [ 48 | :id, 49 | :username, 50 | :discriminator, 51 | :avatar, 52 | bot: false, 53 | verified: :hidden, 54 | email: :hidden 55 | ] 56 | 57 | @typedoc """ 58 | A shortened version of a Guild struct, through the view of a User. 59 | 60 | - `id` 61 | 62 | Represents the guild's id. 63 | - `name` 64 | 65 | Represents the guild's name. 66 | - `icon` 67 | 68 | A string representing the guild's icon hash. 69 | - `owner` 70 | 71 | Whether the user linked to the guild owns it. 72 | - `permissions` 73 | 74 | Bitwise of the user's enabled/disabled permissions. 75 | """ 76 | @type user_guild :: %UserGuild{ 77 | id: snowflake, 78 | name: String.t(), 79 | icon: String.t(), 80 | owner: Boolean, 81 | permissions: Integer 82 | } 83 | defimpl String.Chars, for: __MODULE__ do 84 | def to_string(user), do: user.username <> "#" <> user.discriminator 85 | end 86 | 87 | defmacrop is_valid_img(type, size) do 88 | quote do 89 | unquote(type) in ["png", "webp", "jpg", "gif"] and 90 | unquote(size) in [128, 256, 512, 1024, 2048] 91 | end 92 | end 93 | 94 | @doc """ 95 | Used to get the url for a user's avatar 96 | 97 | `type` must be one of `"png"`, `"webp"`, `"jpg"`, `"gif"` 98 | 99 | `size` must be one of `128`, `256`, `512`, `1024`, `2048` 100 | 101 | ## Examples 102 | ```elixir 103 | > User.avatar_url(user) 104 | https://cdn.discordapp.com/avatars/... 105 | ``` 106 | """ 107 | @spec avatar_url(__MODULE__.t(), String.t(), Integer) :: url 108 | def avatar_url(user) do 109 | avatar_url(user, "jpg", 128) 110 | end 111 | 112 | def avatar_url(user, type, size) when is_valid_img(type, size) do 113 | base = "https://cdn.discordapp.com/avatars/#{user.id}/#{user.avatar}." 114 | base <> "#{type}?size=#{size}" 115 | end 116 | 117 | def avatar_url(_user, _type, _size) do 118 | raise ArgumentError, message: "invalid type and/or size" 119 | end 120 | 121 | @doc """ 122 | Returns a string that mentions a user when used in a message 123 | """ 124 | def mention(user) do 125 | "<@#{user.id}>" 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/Discord/Gateway/manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Gateway.Manager do 2 | @moduledoc false 3 | # Serves as a gatekeeper of sorts, deciding when to let the supervisor spawn new 4 | # gateway connections. It also keeps track of the url, and where the sharding is. 5 | # This module is in control of the supervisors child spawning. 6 | use GenServer 7 | require Logger 8 | alias Alchemy.Discord.Gateway 9 | alias Alchemy.Discord.Gateway.RateLimiter 10 | alias Alchemy.Discord.Api 11 | import Supervisor.Spec 12 | 13 | ### Public ### 14 | 15 | def shard_count do 16 | GenServer.call(GatewayManager, :shard_count) 17 | end 18 | 19 | def request_url do 20 | GenServer.call(GatewayManager, :url_req) 21 | end 22 | 23 | ### Private Utility ### 24 | 25 | defp get_url(_token, selfbot: _) do 26 | json = 27 | Api.get!("https://discord.com/api/v6/gateway").body 28 | |> (fn x -> Poison.Parser.parse!(x, %{}) end).() 29 | 30 | {json["url"] <> "?v=6&encoding=json", 1} 31 | end 32 | 33 | defp get_url(token, []) do 34 | url = "https://discord.com/api/v6/gateway/bot" 35 | 36 | json = 37 | Api.get!(url, token).body 38 | |> (fn x -> Poison.Parser.parse!(x, %{}) end).() 39 | 40 | case json do 41 | %{"retry_after" => ms} -> 42 | :timer.sleep(ms) 43 | get_url(token, []) 44 | 45 | _ -> 46 | {json["url"] <> "?v=6&encoding=json", json["shards"]} 47 | end 48 | end 49 | 50 | defp now, do: DateTime.utc_now() |> DateTime.to_unix() 51 | 52 | ### Server ### 53 | 54 | def start_supervisor do 55 | children = [ 56 | worker(Gateway, []) 57 | ] 58 | 59 | Supervisor.start_link(children, strategy: :simple_one_for_one) 60 | end 61 | 62 | def start_link(token, options) do 63 | GenServer.start_link(__MODULE__, {token, options}, name: GatewayManager) 64 | end 65 | 66 | def init({token, options}) do 67 | {url, shards} = get_url(token, options) 68 | Logger.info("Starting up #{shards} shards") 69 | {:ok, sup} = start_supervisor() 70 | state = %{url: url, url_reset: now(), shards: shards, token: token, supervisor: sup} 71 | GenServer.cast(GatewayManager, {:start_shard, 0}) 72 | {:ok, state} 73 | end 74 | 75 | def handle_call(:shard_count, _from, state) do 76 | {:reply, state.shards, state} 77 | end 78 | 79 | def handle_call(:url_req, _from, state) do 80 | now = now() 81 | wait_time = state.url_reset - now 82 | 83 | cond do 84 | wait_time <= 0 -> 85 | response = fn -> state.url end 86 | 87 | # Increase the url_reset time as a process 88 | # has successfull requested the url 89 | {:reply, response, %{state | url_reset: now + 5}} 90 | 91 | true -> 92 | response = fn -> 93 | Process.sleep(wait_time * 1000) 94 | request_url() 95 | end 96 | 97 | # Don't increate the url_reset as the process 98 | # has not been successfull in requesting the url. 99 | {:reply, response, state} 100 | end 101 | end 102 | 103 | def handle_cast({:start_shard, num}, %{shards: shards} = state) 104 | when num == shards do 105 | {:noreply, state} 106 | end 107 | 108 | def handle_cast({:start_shard, num}, state) do 109 | args = [state.token, [num, state.shards]] 110 | # We don't want to block the server waiting for url requests and whatnot. 111 | Task.start(fn -> 112 | {:ok, pid} = Supervisor.start_child(state.supervisor, args) 113 | RateLimiter.add_handler(pid) 114 | end) 115 | 116 | Logger.debug("Starting shard [#{num}, #{state.shards}]") 117 | {:noreply, state} 118 | end 119 | 120 | def handle_info({:next_shard, [shard | _]}, state) do 121 | GenServer.cast(GatewayManager, {:start_shard, shard + 1}) 122 | {:noreply, state} 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/Discord/Gateway/ratelimiter.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Gateway.RateLimiter do 2 | @moduledoc false 3 | # This servers as a limiter to outside requests to the individual gateways 4 | use Bitwise 5 | alias Alchemy.Discord.Payloads 6 | 7 | defmodule RateSupervisor do 8 | @moduledoc false 9 | alias Alchemy.Discord.Gateway.RateLimiter 10 | use Supervisor 11 | 12 | def start_link do 13 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 14 | end 15 | 16 | def init(:ok) do 17 | children = [ 18 | worker(RateLimiter, []) 19 | ] 20 | 21 | supervise(children, strategy: :simple_one_for_one) 22 | end 23 | end 24 | 25 | def add_handler(pid) do 26 | Supervisor.start_child(__MODULE__.RateSupervisor, [pid]) 27 | end 28 | 29 | def status_update(pid, idle_since, game_info) do 30 | Task.async(fn -> 31 | payload = Payloads.status_update(idle_since, game_info) 32 | send_request(pid, {:status_update, payload}) 33 | end) 34 | end 35 | 36 | def shard_pid(guild, name \\ __MODULE__.RateSupervisor) do 37 | {guild_id, _} = Integer.parse(guild) 38 | 39 | shards = 40 | Supervisor.which_children(name) 41 | |> Enum.map(fn {_, pid, _, _} -> pid end) 42 | 43 | Enum.at(shards, rem(guild_id >>> 22, length(shards))) 44 | end 45 | 46 | def request_guild_members(guild_id, username, limit) do 47 | payload = Payloads.request_guild_members(guild_id, username, limit) 48 | 49 | shard_pid(guild_id) 50 | |> send_request({:send_event, payload}) 51 | end 52 | 53 | def change_voice_state(guild_id, channel_id, mute \\ false, deaf \\ false) do 54 | payload = Payloads.voice_update(guild_id, channel_id, mute, deaf) 55 | 56 | shard_pid(guild_id) 57 | |> send_request({:send_event, payload}) 58 | end 59 | 60 | # Handles the rate 61 | defp send_request(pid, request) do 62 | case GenServer.call(pid, request) do 63 | :ok -> 64 | :ok 65 | 66 | {:wait, time} -> 67 | Process.sleep(time) 68 | send_request(pid, request) 69 | end 70 | end 71 | 72 | defp handle_wait(now, reset_time) do 73 | wait_time = reset_time - now 74 | 75 | if wait_time < 0 do 76 | :go 77 | else 78 | {:wait, wait_time} 79 | end 80 | end 81 | 82 | defp wait_protocol(data, timeout, section, state) do 83 | now = System.monotonic_time(:milliseconds) 84 | 85 | case handle_wait(now, get_in(state, [section, :reset])) do 86 | :go -> 87 | send(state.gateway, {:send_event, data}) 88 | 89 | new = 90 | update_in(state, [section], fn x -> 91 | %{x | left: 0, reset: now + timeout} 92 | end) 93 | 94 | {:reply, :ok, new} 95 | 96 | {:wait, time} -> 97 | {:reply, {:wait, time}, state} 98 | end 99 | end 100 | 101 | def start_link(gateway) do 102 | GenServer.start_link(__MODULE__, {:ok, gateway}) 103 | end 104 | 105 | def init({:ok, gateway}) do 106 | now = System.monotonic_time(:millisecond) 107 | 108 | state = %{ 109 | gateway: gateway, 110 | status_update: %{left: 1, reset: now + 12_000}, 111 | events: %{left: 100, reset: now + 60_000} 112 | } 113 | 114 | {:ok, state} 115 | end 116 | 117 | def handle_call({:send_event, data}, _from, %{events: %{left: 0}} = state) do 118 | wait_protocol(data, 60_000, :events, state) 119 | end 120 | 121 | def handle_call({:send_event, data}, _from, state) do 122 | send(state.gateway, {:send_event, data}) 123 | {:reply, :ok, update_in(state.events.left, &(&1 - 1))} 124 | end 125 | 126 | def handle_call({:status_update, data}, _from, %{status_update: %{left: 1}} = state) do 127 | send(state.gateway, {:send_event, data}) 128 | {:reply, :ok, update_in(state.status_update.left, &(&1 - 1))} 129 | end 130 | 131 | def handle_call({:status_update, data}, _from, state) do 132 | wait_protocol(data, 12_000, :status_update, state) 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/Discord/rate_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.RateManager do 2 | @moduledoc false 3 | # Used to keep track of rate limits. All api requests must request permission to 4 | # run from here. 5 | use GenServer 6 | require Logger 7 | import Alchemy.Discord.RateLimits 8 | 9 | def start_link(token) do 10 | GenServer.start_link(__MODULE__, token, name: API) 11 | end 12 | 13 | # Wrapper method around applying for a slot 14 | def apply(route) do 15 | GenServer.call(API, {:apply, route}) 16 | end 17 | 18 | # Applies for a bucket, waiting and retrying if it fails to get a slot 19 | def send_req(req, route) do 20 | case apply(route) do 21 | {:wait, n} -> 22 | Process.sleep(n) 23 | send_req(req, route) 24 | 25 | {:go, token} -> 26 | process_req(req, token, route) 27 | end 28 | end 29 | 30 | # Wrapper method around processing an API response 31 | def process(result, route) do 32 | GenServer.call(API, {:process, route, result}) 33 | end 34 | 35 | # Performs the request, and then sends the info back to the genserver to handle 36 | defp process_req({m, f, a}, token, route) do 37 | result = apply(m, f, [token | a]) 38 | 39 | case process(result, route) do 40 | {:retry, time} -> 41 | Logger.info( 42 | "Local rate limit encountered for route #{route}" <> 43 | "\n retrying in #{time} ms." 44 | ) 45 | 46 | Process.sleep(time) 47 | send_req({m, f, a}, route) 48 | 49 | done -> 50 | done 51 | end 52 | end 53 | 54 | defmodule State do 55 | @moduledoc false 56 | defstruct [:token, :global, :rates] 57 | end 58 | 59 | def init(token) do 60 | table = :ets.new(:rates, [:named_table]) 61 | {:ok, %State{token: token, rates: table}} 62 | end 63 | 64 | defp get_rates(route) do 65 | case :ets.lookup(:rates, route) do 66 | [{^route, info}] -> info 67 | [] -> default_info() 68 | end 69 | end 70 | 71 | defp update_rates(_route, nil) do 72 | nil 73 | end 74 | 75 | defp update_rates(route, info) do 76 | :ets.insert(:rates, {route, info}) 77 | end 78 | 79 | defp set_global(timeout) do 80 | GenServer.call(API, {:set_global, timeout}) 81 | end 82 | 83 | def throttle(%{remaining: remaining} = rates) when remaining > 0 do 84 | {:go, %{rates | remaining: remaining - 1}} 85 | end 86 | 87 | def throttle(rates) do 88 | now = :os.system_time(:millisecond) / 1000 89 | wait_time = rates.reset_time - now 90 | 91 | cond do 92 | wait_time > 0 -> 93 | {:wait, Kernel.ceil(wait_time * 1000)} 94 | 95 | # this means the reset_time has passed 96 | true -> 97 | {:go, %{rates | remaining: rates.limit - 1, reset_time: now + 2}} 98 | end 99 | end 100 | 101 | def handle_call({:apply, route}, _, state) do 102 | case throttle(get_rates(route)) do 103 | {:wait, time} -> 104 | Logger.debug("Timeout of #{time} under route #{route}") 105 | {:reply, {:wait, time}, state} 106 | 107 | {:go, new_rates} -> 108 | update_rates(route, new_rates) 109 | {:reply, {:go, state.token}, state} 110 | end 111 | end 112 | 113 | def handle_call({:process, route, result}, _, state) do 114 | response = 115 | case result do 116 | {:ok, data, rates} -> 117 | update_rates(route, rates) 118 | {:ok, data} 119 | 120 | {:local, timeout, rates} -> 121 | update_rates(route, rates) 122 | {:retry, timeout} 123 | 124 | {:global, timeout} -> 125 | set_global(timeout) 126 | {:retry, timeout} 127 | 128 | error -> 129 | error 130 | end 131 | 132 | {:reply, response, state} 133 | end 134 | 135 | def handle_call({:set_global, timeout}, _, state) do 136 | Task.start(fn -> 137 | Process.sleep(timeout) 138 | GenServer.call(API, :reset_global) 139 | end) 140 | 141 | {:reply, :ok, %{state | global: {:wait, timeout}}} 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alchemy 2 | 3 | A Discord library / framework for Elixir. 4 | 5 | This library aims to provide a solid foundation, upon which to build 6 | a simple, yet powerful interface. Unlike other libraries, this one comes 7 | along with a framework for defining commands, and event hooks. No need 8 | to mess around with consumers, or handlers, defining a command is as simple 9 | as defining a function! 10 | 11 | 12 | ### Installation 13 | Simply add *Alchemy* to your dependencies in your `mix.exs` file: 14 | ```elixir 15 | def deps do 16 | [{:alchemy, "~> 0.7.0", hex: :discord_alchemy}] 17 | end 18 | ``` 19 | 20 | ### [Docs](https://hexdocs.pm/discord_alchemy/0.6.9) 21 | 22 | This is the stable documentation for the library, I highly recommend going 23 | through it, as most of the relevant information resides there. 24 | 25 | ### QuickStart 26 | Run `mix alchemy.init` to generate a template bot file for your project. 27 | 28 | ### Getting Started 29 | The first thing we need to do is define some kind of application for our bot. 30 | Thankfully, the `Application` module encapsulates this need. 31 | ```elixir 32 | defmodule MyBot do 33 | use Application 34 | alias Alchemy.Client 35 | 36 | 37 | defmodule Commands do 38 | use Alchemy.Cogs 39 | 40 | Cogs.def ping do 41 | Cogs.say "pong!" 42 | end 43 | end 44 | 45 | 46 | def start(_type, _args) do 47 | run = Client.start("your token here") 48 | use Commands 49 | run 50 | end 51 | end 52 | ``` 53 | So we defined what we call a `Cog` in the `Commands` module, a cog 54 | is simply a module that contains commands. To wire up this command into the bot, 55 | we need to `use` the module, which we do after starting the client. We need 56 | to provide a valid return type in `start/2`, which is why we capture the result 57 | of `Client.start` in a variable. 58 | 59 | Now all we need to do to wire up this application, is to add it to our `mix.exs`: 60 | ```elixir 61 | def application do 62 | [mod: {MyBot, []}] 63 | end 64 | ``` 65 | This makes our bot automatically start when we run our project. 66 | Now, to run this project, we have 2 options: 67 | - use `mix run --no-halt` (the flags being necessary to 68 | prevent the app from ending once our `start/2` function finishes) 69 | - or use `iex -S mix` to start our application in the repl. 70 | 71 | Starting the application in the repl is very advantageous, as it allows 72 | you to interact with the bot live. 73 | 74 | ### Using Voice 75 | Alchemy also supports using Discord's Voice API to play audio. 76 | We rely on [ffmpeg](https://ffmpeg.org/) for audio encoding, 77 | as well as [youtube-dl](https://rg3.github.io/youtube-dl/) for streaming 78 | audio from sites. Before the Voice API can be used, you'll need to acquire 79 | the latest versions of those from their sites (make sure you get ffmpeg 80 | with opus support), and then configure the path to those executables in 81 | alchemy like so: 82 | ``` 83 | # in config.exs 84 | config :alchemy, 85 | ffmpeg_path: "path/to/ffmpeg", 86 | youtube_dl_path: "path/to/youtube_dl" 87 | ``` 88 | 89 | Now you're all set to start playing some audio! 90 | 91 | The first step is to connect to a voice channel with `Alchemy.Voice.join/2`, 92 | then, you can start playing audio with `Alchemy.Voice.play_file/2`, 93 | or `Alchemy.Voice.play_url/2`. Here's an example command to show off these 94 | features: 95 | ```elixir 96 | Cogs.def play(url) do 97 | {:ok, guild} = Cogs.guild() 98 | default_voice_channel = Enum.find(guild.channels, &match?(%{type: 2}, &1)) 99 | # joins the default channel for this guild 100 | # this will check if a connection already exists for you 101 | Alchemy.Voice.join(guild.id, default_voice_channel.id) 102 | Alchemy.Voice.play_url(guild.id, url) 103 | Cogs.say "Now playing #{url}" 104 | end 105 | ``` 106 | 107 | ### Porcelain 108 | Alchemy uses [`Porcelain`](https://github.com/alco/porcelain), to 109 | help with managing external processes, to help save on memory usage, 110 | you may want to use the `goon` driver, as suggested by `Porcelain`. 111 | For more information, check out their GitHub. 112 | 113 | # Other Examples 114 | If you'd like to see a larger example of a bot using `Alchemy`, 115 | checkout out [Viviani](https://github.com/cronokirby/viviani). 116 | -------------------------------------------------------------------------------- /lib/Discord/api.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Api do 2 | @moduledoc false 3 | require Logger 4 | alias Alchemy.Discord.RateLimits 5 | 6 | ### Utility ### 7 | 8 | # Converts a keyword list into json 9 | def encode(options) do 10 | options |> Enum.into(%{}) |> Poison.encode!() 11 | end 12 | 13 | def query([]) do 14 | "" 15 | end 16 | 17 | def query(options) do 18 | "?" <> 19 | Enum.map_join(options, "&", fn {opt, val} -> 20 | "#{opt}=#{val}" 21 | end) 22 | end 23 | 24 | # returns a function to be used in api requests 25 | def parse_map(mod) do 26 | fn json -> 27 | json 28 | |> (fn x -> Poison.Parser.parse!(x, %{}) end).() 29 | |> Enum.map(&mod.from_map/1) 30 | end 31 | end 32 | 33 | ### Request API ### 34 | 35 | def get(url, token, body) do 36 | request(:get, url, token) 37 | |> handle(body) 38 | end 39 | 40 | def patch(url, token, data \\ "", body \\ :no_parser) do 41 | request(:patch, url, data, token) 42 | |> handle(body) 43 | end 44 | 45 | def post(url, token, data \\ "", body \\ :no_parser) do 46 | request(:post, url, data, token) 47 | |> handle(body) 48 | end 49 | 50 | def put(url, token, data \\ "") do 51 | request(:put, url, data, token) 52 | |> handle(:no_parser) 53 | end 54 | 55 | def delete(url, token, body \\ :no_parser) do 56 | request(:delete, url, token) 57 | |> handle(body) 58 | end 59 | 60 | def image_data(url) do 61 | {:ok, HTTPoison.get(url).body |> Base.encode64()} 62 | end 63 | 64 | # Fetches an image, encodes it base64, and then formats it in discord's 65 | # preferred formatting. Returns {:ok, formatted}, or {:error, why} 66 | def fetch_avatar(url) do 67 | {:ok, data} = image_data(url) 68 | {:ok, "data:image/jpeg;base64,#{data}"} 69 | end 70 | 71 | ### Private ### 72 | 73 | # gets the auth headers, checking for selfbot 74 | def auth_headers(token) do 75 | client_type = Application.get_env(:alchemy, :self_bot, "Bot ") 76 | 77 | [ 78 | {"Authorization", client_type <> "#{token}"}, 79 | {"User-Agent", "DiscordBot (https://github.com/cronokirby/alchemy, 0.6.0)"} 80 | ] 81 | end 82 | 83 | def request(type, url, token) do 84 | apply(HTTPoison, type, [url, auth_headers(token)]) 85 | end 86 | 87 | def request(type, url, data, token) do 88 | headers = auth_headers(token) 89 | headers = [{"Content-Type", "application/json"} | headers] 90 | headers = [{"X-RateLimit-Precision", "millisecond"} | headers] 91 | apply(HTTPoison, type, [url, data, headers]) 92 | end 93 | 94 | def handle(response, :no_parser) do 95 | handle_response(response, :no_parser) 96 | end 97 | 98 | def handle(response, module) when is_atom(module) do 99 | handle_response(response, &module.from_map(Poison.Parser.parse!(&1, %{}))) 100 | end 101 | 102 | def handle(response, parser) when is_function(parser) do 103 | handle_response(response, parser) 104 | end 105 | 106 | def handle(response, struct) do 107 | handle_response(response, &Poison.decode!(&1, as: struct)) 108 | end 109 | 110 | defp handle_response({:error, %HTTPoison.Error{reason: why}}, _) do 111 | {:error, why} 112 | end 113 | 114 | # Ratelimit status code 115 | defp handle_response({:ok, %{status_code: 429} = response}, _) do 116 | RateLimits.rate_info(response) 117 | end 118 | 119 | defp handle_response({:ok, %{status_code: code} = response}, :no_parser) 120 | when code in 200..299 do 121 | rate_info = RateLimits.rate_info(response) 122 | {:ok, nil, rate_info} 123 | end 124 | 125 | defp handle_response({:ok, %{status_code: code} = response}, decoder) 126 | when code in 200..299 do 127 | rate_info = RateLimits.rate_info(response) 128 | struct = decoder.(response.body) 129 | {:ok, struct, rate_info} 130 | end 131 | 132 | defp handle_response({:ok, response}, _) do 133 | {:error, response.body} 134 | end 135 | 136 | # This is necessary in a few places to bypass the error handling: 137 | # i.e. the Gateway url requests. 138 | def get!(url) do 139 | HTTPoison.get!(url) 140 | end 141 | 142 | def get!(url, token) do 143 | HTTPoison.get!(url, auth_headers(token)) 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /docs/Intro.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | After installing your dependencies and whatnot, it's time to write your bot! 3 | (If you just want a template to work from, you can use `mix alchemy.init` 4 | to generate one quickly.) 5 | 6 | The first thing we need to do is define some kind of application for our bot. 7 | Thankfully, the `Application` module encapsulates this need. 8 | ```elixir 9 | defmodule MyBot do 10 | use Application 11 | alias Alchemy.Client 12 | 13 | def start(_type, _args) do 14 | Client.start("your token here") 15 | end 16 | end 17 | ``` 18 | The `Client.start/2` function sets up the necessary client connections to discord; 19 | because of this, not much can really be done before this function is called. 20 | 21 | At this point, we have our bot running, but it does nothing! Let's add a command: 22 | ```elixir 23 | defmodule MyBot.Commands do 24 | use Alchemy.Cogs 25 | 26 | Cogs.def ping do 27 | Cogs.say "pong!" 28 | end 29 | end 30 | ``` 31 | The first thing we do in this module is `use Alchemy.Cogs` this sets up our module 32 | to be able to define commands, which we can later plug into our bot. We use the 33 | `Cogs.def` macro to define a command; command definition is very similar to commands, 34 | in fact, pattern matching and guards still work just as they would in normal functions, and in fact, they're very useful in writing useful commands! 35 | This command will get triggered anytime a user types 36 | `!ping` in the chat. We can also change the command prefix using 37 | `Cogs.set_prefix/1`. In the command itself, we simply send a message 38 | back to the same channel with `Cogs.say`, and that's it! 39 | 40 | ### Loading a Cog 41 | Now to load the Cog into our application, all we need to do is `use` it: 42 | ```elixir 43 | def start(_type, _args) do 44 | run = Client.start("your token here") 45 | use MyBot.Commands 46 | run 47 | end 48 | ``` 49 | This will load up all the commands we defined in the module, and make them 50 | ready to use. We can also do this dynamically from the repl, `use Module` 51 | will work there as well. If at any time we want to unload a module, 52 | `Cogs.unload/1` is quite handy. If we just need to disable a single command, 53 | `Cogs.disable/1` is also useful. 54 | 55 | ### Adding the application to our `mix` 56 | 57 | Now all we need to do to wire up this application, is to add it to our `mix.exs`: 58 | ```elixir 59 | def application do 60 | [mod: {Mybot, []}] 61 | end 62 | ``` 63 | This makes our bot automatically start when we run our project. 64 | 65 | ### Running our application 66 | 67 | Now, to run this project, we have 2 options: 68 | - use `mix run --no-halt` (the flags being necessary to 69 | prevent the app from ending once our `start/2` function finishes) 70 | - or use `iex -S mix` to start our application in the repl. 71 | 72 | Starting the application in the repl is very advantageous, as it allows 73 | you to interact with the bot live. 74 | 75 | ### Using Voice 76 | Alchemy also supports using discord's voice API to play audio. 77 | We rely on [ffmpeg](https://ffmpeg.org/) for audio encoding, 78 | as well as [youtube-dl](https://rg3.github.io/youtube-dl/) for streaming 79 | audio from sites. Before the voice api can be used, you'll need to acquire 80 | the latest versions of those from their sites (make sure you get ffmpeg 81 | with opus support), and then configure the path to those executables in 82 | alchemy like so: 83 | ``` 84 | # in config.exs 85 | config :alchemy, 86 | ffmpeg_path: "path/to/ffmpeg", 87 | youtube_dl_path: "path/to/youtube_dl" 88 | ``` 89 | 90 | Now you're all set to start playing some audio! 91 | 92 | The first step is to connect to a voice channel with `Alchemy.Voice.join/2`, 93 | then, you can start playing audio with `Alchemy.Voice.play_file/2`, 94 | or `Alchemy.Voice.play_url/2`. Here's an example command to show off these 95 | features: 96 | ```elixir 97 | Cogs.def play(url) do 98 | {:ok, id} = Cogs.guild_id() 99 | # joins the default channel for this guild 100 | # this will check if a connection already exists for you 101 | Voice.join(id, id) 102 | Voice.play_url(id, url) 103 | Cogs.say "Now playing #{url}" 104 | end 105 | ``` 106 | ### Where to go now 107 | I'd recommend taking a look at the `Alchemy.Cogs` module for more examples 108 | of defining commands, and how to make use of pattern matching in them. 109 | 110 | If you want to learn about event hooks, check out the `Alchemy.Events` module. 111 | 112 | If you want to dig through the many api functions available, check out 113 | `Alchemy.Client`. 114 | -------------------------------------------------------------------------------- /lib/Structs/webhook.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Webhook do 2 | @moduledoc """ 3 | """ 4 | alias Alchemy.Discord.Webhooks 5 | alias Alchemy.{Embed, User} 6 | import Alchemy.Discord.RateManager, only: [send_req: 2] 7 | 8 | @type snowflake :: String.t() 9 | 10 | @type t :: %__MODULE__{ 11 | id: snowflake, 12 | guild_id: snowflake | nil, 13 | channel_id: snowflake, 14 | user: User.t() | nil, 15 | name: String.t() | nil, 16 | avatar: String.t() | nil, 17 | token: String.t() 18 | } 19 | 20 | defstruct [:id, :guild_id, :channel_id, :user, :name, :avatar, :token] 21 | 22 | @doc """ 23 | Creates a new webhook in a channel. 24 | 25 | The name parameter is mandatory, and specifies the name of the webhook. 26 | of course. 27 | ## Options 28 | - `avatar` 29 | A link to a 128x128 image to act as the avatar of the webhook. 30 | ## Examples 31 | ```elixir 32 | {:ok, hook} = Webhook.create("66666", "The Devil") 33 | ``` 34 | """ 35 | @spec create(snowflake, String.t(), avatar: String.t()) :: 36 | {:ok, __MODULE__.t()} 37 | | {:error, term} 38 | def create(channel_id, name, options \\ []) do 39 | {Webhooks, :create_webhook, [channel_id, name, options]} 40 | |> send_req("/channels/webhooks") 41 | end 42 | 43 | @doc """ 44 | Returns a list of all webhooks in a channel. 45 | 46 | ## Examples 47 | ```elixir 48 | {:ok, [%Webhook{} | _]} = Webhook.in_channel("6666") 49 | ``` 50 | """ 51 | @spec in_channel(snowflake) :: {:ok, [__MODULE__.t()]} | {:error, term} 52 | def in_channel(channel_id) do 53 | {Webhooks, :channel_webhooks, [channel_id]} 54 | |> send_req("/channels/webhooks") 55 | end 56 | 57 | @doc """ 58 | Returns a list of all webhooks in a guild. 59 | 60 | ## Examples 61 | ```elixir 62 | {:ok, [%Webhook{} | _]} = Webhook.in_guild("99999") 63 | ``` 64 | """ 65 | @spec in_guild(atom) :: {:ok, [__MODULE__.t()]} | {:error, term} 66 | def in_guild(guild_id) do 67 | {Webhooks, :guild_webhooks, [guild_id]} 68 | |> send_req("/guilds/webhooks") 69 | end 70 | 71 | @doc """ 72 | Modifies the settings of a webhook. 73 | 74 | Note that the user field of the webhook will be missing. 75 | 76 | ## Options 77 | - `name` 78 | The name of the webhook. 79 | - `avatar` 80 | A link to a 128x128 icon image. 81 | 82 | ## Examples 83 | ```elixir 84 | {:ok, hook} = Webhook.create("6666", "Captian Hook") 85 | # Let's fix that typo: 86 | Webhook.edit(hook, name: "Captain Hook") 87 | ``` 88 | """ 89 | @spec edit(__MODULE__.t(), name: String.t(), avatar: String.t()) :: 90 | {:ok, __MODULE__.t()} 91 | | {:error, term} 92 | def edit(%__MODULE__{id: id, token: token}, options) do 93 | {Webhooks, :modify_webhook, [id, token, options]} 94 | |> send_req("/webhooks") 95 | end 96 | 97 | @doc """ 98 | Deletes a webhook. 99 | 100 | All you need for this is the webhook itself. 101 | ## Examples 102 | ```elixir 103 | {:ok, wh} = Webhook.create("666", "Captain Hook") 104 | Webhook.delete(wh) 105 | ``` 106 | """ 107 | @spec delete(__MODULE__.t()) :: {:ok, __MODULE__.t()} | {:error, term} 108 | def delete(%__MODULE__{id: id, token: token}) do 109 | {Webhooks, :delete_webhook, [id, token]} 110 | |> send_req("/webhooks") 111 | end 112 | 113 | @doc """ 114 | Sends a message to a webhook. 115 | 116 | `type` must be one of `:embed, :content`; `:embed` requiring an `Embed.t` 117 | struct, and `:content` requiring a string. 118 | ## Options 119 | - `avatar_url` 120 | A link to an image to replace the one the hook has, for this message. 121 | - `username` 122 | The username to override to hook's, for this message. 123 | - `tts` 124 | When set to true, will make the message TTS 125 | ## Examples 126 | ```elixir 127 | {:ok, hook} = Webhook.create("66", "Captain Hook") 128 | Webhook.send(hook, {content: "ARRRRRGH!"}) 129 | ``` 130 | For a more elaborate example: 131 | ```elixir 132 | user = Cache.user() 133 | embed = %Embed{} 134 | |> description("I'm commandeering this vessel!!!") 135 | |> color(0x3a83b8) 136 | Webhook.send(hook, {:embed, embed}, 137 | avatar_url: User.avatar_url(user), 138 | username: user.username) 139 | ``` 140 | """ 141 | @spec send(__MODULE__.t(), {:embed, Embed.t()} | {:content, String.t()}, 142 | avatar_url: String.t(), 143 | username: String.t(), 144 | tts: Boolean 145 | ) :: 146 | {:ok, nil} | {:error, term} 147 | def send(%__MODULE__{id: id, token: token}, {type, content}, options \\ []) do 148 | {type, content} = 149 | case {type, content} do 150 | {:embed, em} -> 151 | {:embeds, [Embed.build(em)]} 152 | 153 | x -> 154 | x 155 | end 156 | 157 | options = Keyword.put(options, type, content) 158 | 159 | {Webhooks, :execute_webhook, [id, token, options]} 160 | |> send_req("/webhooks") 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/Structs/Messages/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Message do 2 | import Alchemy.Structs 3 | alias Alchemy.{User, Attachment, Embed, Reaction, Reaction.Emoji} 4 | 5 | @moduledoc """ 6 | This module contains the types and functions related to messages in discord. 7 | """ 8 | 9 | @type snowflake :: String.t() 10 | @typedoc """ 11 | Represents an iso8601 timestamp. 12 | """ 13 | @type timestamp :: String.t() 14 | @typedoc """ 15 | Represents a message in a channel. 16 | 17 | - `id` 18 | The id of this message. 19 | - `author` 20 | The user who sent this message. This field will be very incomplete 21 | if the message originated from a webhook. 22 | - `content` 23 | The content of the message. 24 | - `timestamp` 25 | The timestamp of the message. 26 | - `edit_timestamp` 27 | The timestamp of when this message was edited, if it ever was. 28 | - `tts` 29 | Whether this was a tts message. 30 | - `mention_everyone` 31 | Whether this message mentioned everyone. 32 | - `mentions` 33 | A list of users this message mentioned. 34 | - `mention_roles` 35 | A list of role ids this message mentioned. 36 | - `attachments` 37 | A list of attachments to the message. 38 | - `embeds` 39 | A list of embeds attached to this message. 40 | - `reactions` 41 | A list of reactions to this message. 42 | - `nonce` 43 | Used for validating a message was sent. 44 | - `pinned` 45 | Whether this message is pinned. 46 | - `webhook_id` 47 | The id of the webhook that sent this message, if it was sent by a webhook. 48 | """ 49 | @type t :: %__MODULE__{ 50 | id: snowflake, 51 | channel_id: snowflake, 52 | author: User.t(), 53 | content: String, 54 | timestamp: timestamp, 55 | edited_timestamp: String | nil, 56 | tts: Boolean, 57 | mention_everyone: Boolean, 58 | mentions: [User.t()], 59 | mention_roles: [snowflake], 60 | attachments: [Attachment.t()], 61 | embeds: [Embed.t()], 62 | reactions: [Reaction.t()], 63 | nonce: snowflake, 64 | pinned: Boolean, 65 | webhook_id: String.t() | nil 66 | } 67 | 68 | defstruct [ 69 | :id, 70 | :channel_id, 71 | :author, 72 | :content, 73 | :timestamp, 74 | :edited_timestamp, 75 | :tts, 76 | :mention_everyone, 77 | :mentions, 78 | :mention_roles, 79 | :attachments, 80 | :embeds, 81 | :reactions, 82 | :nonce, 83 | :pinned, 84 | :webhook_id 85 | ] 86 | 87 | @typedoc """ 88 | Represents a reaction to a message. 89 | 90 | - `count` 91 | Times this specific emoji reaction has been used. 92 | - `me` 93 | Whether this client reacted to the message. 94 | - `emoji` 95 | Information about the emoji used. 96 | """ 97 | @type reaction :: %Reaction{ 98 | count: Integer, 99 | me: Boolean, 100 | emoji: Emoji.t() 101 | } 102 | @typedoc """ 103 | Represents an emoji used to react to a message. 104 | 105 | - `id` 106 | The id of this emoji. `nil` if this isn't a custom emoji. 107 | - `name` 108 | The name of this emoji. 109 | """ 110 | @type emoji :: %Emoji{ 111 | id: String.t() | nil, 112 | name: String.t() 113 | } 114 | 115 | @doc false 116 | def from_map(map) do 117 | map 118 | |> field?("author", User) 119 | |> field_map?("mentions", &map_struct(&1, User)) 120 | |> field_map?("attachments", &map_struct(&1, Attachment)) 121 | |> field_map?("embeds", &Enum.map(&1, fn x -> Embed.from_map(x) end)) 122 | |> field_map?("reactions", &map_struct(&1, Reaction)) 123 | |> to_struct(__MODULE__) 124 | end 125 | 126 | defmacrop matcher(str) do 127 | quote do 128 | fn 129 | unquote(str) <> r -> r 130 | _ -> nil 131 | end 132 | end 133 | end 134 | 135 | @type mention_type :: :roles | :nicknames | :channels | :users 136 | @doc """ 137 | Finds a list of mentions in a string. 138 | 139 | 4 types of mentions exist: 140 | - `roles` 141 | A mention of a specific role. 142 | - `nicknames` 143 | A mention of a user by nickname. 144 | - `users` 145 | A mention of a user by name, or nickname. 146 | - `:channels` 147 | A mention of a channel. 148 | """ 149 | @spec find_mentions(String.t(), mention_type) :: [snowflake] 150 | def find_mentions(content, type) do 151 | matcher = 152 | case type do 153 | :roles -> 154 | matcher("@&") 155 | 156 | :nicknames -> 157 | matcher("@!") 158 | 159 | :channels -> 160 | matcher("#") 161 | 162 | :users -> 163 | fn 164 | "@!" <> r -> r 165 | "@" <> r -> r 166 | _ -> nil 167 | end 168 | end 169 | 170 | Regex.scan(~r/<([#@]?[!&]?[0-9]+)>/, content, capture: :all_but_first) 171 | |> Stream.concat() 172 | |> Stream.map(matcher) 173 | |> Enum.filter(&(&1 != nil)) 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/Voice/gateway.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Voice.Gateway do 2 | @moduledoc false 3 | @behaviour :websocket_client 4 | alias Alchemy.Voice.Supervisor.Server 5 | alias Alchemy.Voice.Controller 6 | alias Alchemy.Voice.UDP 7 | require Logger 8 | 9 | defmodule Payloads do 10 | @moduledoc false 11 | @opcodes %{ 12 | identify: 0, 13 | select: 1, 14 | ready: 2, 15 | heartbeat: 3, 16 | session: 4, 17 | speaking: 5 18 | } 19 | 20 | def build_payload(data, op) do 21 | %{op: @opcodes[op], d: data} 22 | |> Poison.encode!() 23 | end 24 | 25 | def identify(server_id, user_id, session, token) do 26 | %{"server_id" => server_id, "user_id" => user_id, "session_id" => session, "token" => token} 27 | |> build_payload(:identify) 28 | end 29 | 30 | def heartbeat do 31 | now = DateTime.utc_now() |> DateTime.to_unix() 32 | build_payload(now * 1000, :heartbeat) 33 | end 34 | 35 | def select(my_ip, my_port) do 36 | %{ 37 | "protocol" => "udp", 38 | "data" => %{ 39 | "address" => my_ip, 40 | "port" => my_port, 41 | "mode" => "xsalsa20_poly1305" 42 | } 43 | } 44 | |> build_payload(:select) 45 | end 46 | 47 | def speaking(flag) do 48 | %{"speaking" => flag, "delay" => 0} 49 | |> build_payload(:speaking) 50 | end 51 | end 52 | 53 | defmodule State do 54 | @moduledoc false 55 | defstruct [ 56 | :token, 57 | :guild_id, 58 | :channel, 59 | :user_id, 60 | :url, 61 | :session, 62 | :udp, 63 | :discord_ip, 64 | :discord_port, 65 | :my_ip, 66 | :my_port, 67 | :ssrc, 68 | :key 69 | ] 70 | end 71 | 72 | def start_link(url, token, session, user_id, guild_id, channel) do 73 | :crypto.start() 74 | :ssl.start() 75 | url = String.replace(url, ":80", "") 76 | 77 | state = %State{ 78 | token: token, 79 | guild_id: guild_id, 80 | user_id: user_id, 81 | url: url, 82 | session: session, 83 | channel: channel 84 | } 85 | 86 | :websocket_client.start_link("wss://" <> url, __MODULE__, state) 87 | end 88 | 89 | def init(state) do 90 | {:once, state} 91 | end 92 | 93 | def onconnect(_, state) do 94 | # keeping track of the channel helps avoid pointless voice connections 95 | # by letting people ping the registry instead. 96 | Registry.register(Registry.Voice, {state.guild_id, :gateway}, state.channel) 97 | Logger.debug("Voice Gateway for #{state.guild_id} connected") 98 | send(self(), :send_identify) 99 | {:ok, state} 100 | end 101 | 102 | def ondisconnect(reason, state) do 103 | Logger.debug( 104 | "Voice Gateway for #{state.guild_id} disconnected, " <> 105 | "reason: #{inspect(reason)}" 106 | ) 107 | 108 | if state.udp do 109 | :gen_udp.close(state.udp) 110 | end 111 | 112 | {:ok, state} 113 | end 114 | 115 | def websocket_handle({:text, msg}, _, state) do 116 | msg |> (fn x -> Poison.Parser.parse!(x, %{}) end).() |> dispatch(state) 117 | end 118 | 119 | def dispatch(%{"op" => 2, "d" => payload}, state) do 120 | {my_ip, my_port, discord_ip, udp} = 121 | UDP.open_udp(payload["ip"], payload["port"], payload["ssrc"]) 122 | 123 | new_state = %{ 124 | state 125 | | my_ip: my_ip, 126 | my_port: my_port, 127 | discord_ip: discord_ip, 128 | discord_port: payload["port"], 129 | udp: udp, 130 | ssrc: payload["ssrc"] 131 | } 132 | 133 | {:reply, {:text, Payloads.select(my_ip, my_port)}, new_state} 134 | end 135 | 136 | def dispatch(%{"op" => 4, "d" => payload}, state) do 137 | send(self(), {:start_controller, self()}) 138 | {:ok, %{state | key: :erlang.list_to_binary(payload["secret_key"])}} 139 | end 140 | 141 | def dispatch(%{"op" => 8, "d" => payload}, state) do 142 | send(self(), {:heartbeat, floor(payload["heartbeat_interval"] * 0.75)}) 143 | {:ok, state} 144 | end 145 | 146 | def dispatch(_, state) do 147 | {:ok, state} 148 | end 149 | 150 | def websocket_info(:send_identify, _, state) do 151 | payload = Payloads.identify(state.guild_id, state.user_id, state.session, state.token) 152 | {:reply, {:text, payload}, state} 153 | end 154 | 155 | def websocket_info({:heartbeat, interval}, _, state) do 156 | Process.send_after(self(), {:heartbeat, interval}, interval) 157 | {:reply, {:text, Payloads.heartbeat()}, state} 158 | end 159 | 160 | def websocket_info({:start_controller, me}, _, state) do 161 | {:ok, pid} = 162 | Controller.start_link( 163 | state.udp, 164 | state.key, 165 | state.ssrc, 166 | state.discord_ip, 167 | state.discord_port, 168 | state.guild_id, 169 | me 170 | ) 171 | 172 | Server.send_to(state.guild_id, pid) 173 | {:ok, state} 174 | end 175 | 176 | def websocket_info({:speaking, flag}, _, state) do 177 | {:reply, {:text, Payloads.speaking(flag)}, state} 178 | end 179 | 180 | def websocket_terminate(why, _conn_state, state) do 181 | Logger.debug( 182 | "Voice Gateway for #{state.guild_id} terminated, " <> 183 | "reason: #{inspect(why)}" 184 | ) 185 | 186 | :ok 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /lib/Discord/Endpoints/channels.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Channels do 2 | @moduledoc false 3 | alias Poison.Parser 4 | alias Alchemy.Discord.Api 5 | alias Alchemy.{Channel, Channel.Invite, Channel.DMChannel, Message, User, Reaction.Emoji} 6 | 7 | @root "https://discord.com/api/v6/channels/" 8 | 9 | def parse_channel(json) do 10 | parsed = Parser.parse!(json, %{}) 11 | 12 | if parsed["type"] == 1 do 13 | DMChannel.from_map(parsed) 14 | else 15 | Channel.from_map(parsed) 16 | end 17 | end 18 | 19 | def get_channel(token, channel_id) do 20 | Api.get(@root <> channel_id, token, &parse_channel/1) 21 | end 22 | 23 | def modify_channel(token, channel_id, options) do 24 | Api.patch(@root <> channel_id, token, Api.encode(options), Channel) 25 | end 26 | 27 | def delete_channel(token, channel_id) do 28 | Api.delete(@root <> channel_id, token, &parse_channel/1) 29 | end 30 | 31 | def channel_messages(token, channel_id, options) do 32 | (@root <> channel_id <> "/messages" <> Api.query(options)) 33 | |> Api.get(token, Api.parse_map(Message)) 34 | end 35 | 36 | def channel_message(token, channel_id, message_id) do 37 | (@root <> channel_id <> "/messages/" <> message_id) 38 | |> Api.get(token, Message) 39 | end 40 | 41 | def create_message(token, channel_id, options) do 42 | url = @root <> channel_id <> "/messages" 43 | 44 | case Keyword.pop(options, :file) do 45 | {nil, options} -> 46 | Api.post(url, token, Api.encode(options), Message) 47 | 48 | # This branch requires a completely different request 49 | {file, options} -> 50 | options = 51 | case Keyword.pop(options, :embed) do 52 | {nil, options} -> 53 | options 54 | 55 | {embed, options} -> 56 | embed = %{"embed" => embed} |> Poison.encode!() 57 | [{:payload_json, embed} | options] 58 | end 59 | |> Enum.map(fn {k, v} -> {Atom.to_string(k), v} end) 60 | 61 | data = {:multipart, [{:file, file} | options]} 62 | 63 | headers = [ 64 | {"Content-Type", "multipart/form-data"} 65 | | Api.auth_headers(token) 66 | ] 67 | 68 | HTTPoison.post(url, data, headers) 69 | |> Api.handle(Message) 70 | end 71 | end 72 | 73 | def edit_message(token, channel_id, message_id, options) do 74 | (@root <> channel_id <> "/messages/" <> message_id) 75 | |> Api.patch(token, Api.encode(options), Message) 76 | end 77 | 78 | def delete_message(token, channel_id, message_id) do 79 | (@root <> channel_id <> "/messages/" <> message_id) 80 | |> Api.delete(token) 81 | end 82 | 83 | def delete_messages(token, channel_id, messages) do 84 | json = Poison.encode!(%{messages: messages}) 85 | 86 | (@root <> channel_id <> "/messages/bulk-delete") 87 | |> Api.post(token, json) 88 | end 89 | 90 | defp modify_reaction(token, channel_id, message_id, %Emoji{id: nil, name: name}, stub, request) do 91 | (@root <> 92 | channel_id <> 93 | "/messages/" <> 94 | message_id <> 95 | "/reactions/" <> name <> stub) 96 | |> URI.encode() 97 | |> request.(token) 98 | end 99 | 100 | defp modify_reaction(token, channel_id, message_id, %Emoji{id: id, name: name}, stub, request) do 101 | (@root <> 102 | channel_id <> 103 | "/messages/" <> 104 | message_id <> 105 | "/reactions/" <> ":#{name}:#{id}" <> stub) 106 | |> URI.encode() 107 | |> request.(token) 108 | end 109 | 110 | def create_reaction(token, channel_id, message_id, emoji) do 111 | modify_reaction(token, channel_id, message_id, emoji, "/@me", &Api.put/2) 112 | end 113 | 114 | def delete_own_reaction(token, channel_id, message_id, emoji) do 115 | modify_reaction(token, channel_id, message_id, emoji, "/@me", &Api.delete/2) 116 | end 117 | 118 | def delete_reaction(token, channel_id, message_id, emoji, user_id) do 119 | stub = "/#{user_id}" 120 | modify_reaction(token, channel_id, message_id, emoji, stub, &Api.delete/2) 121 | end 122 | 123 | def get_reactions(token, channel_id, message_id, %Emoji{id: nil, name: name}) do 124 | (@root <> 125 | channel_id <> 126 | "/messages/" <> 127 | message_id <> 128 | "/reactions/" <> 129 | name) 130 | |> URI.encode() 131 | |> Api.get(token, [%User{}]) 132 | end 133 | 134 | def get_reactions(token, channel_id, message_id, %Emoji{id: id, name: name}) do 135 | (@root <> 136 | channel_id <> 137 | "/messages/" <> 138 | message_id <> 139 | "/reactions/" <> 140 | ":#{name}:#{id}") 141 | |> URI.encode() 142 | |> Api.get(token, [%User{}]) 143 | end 144 | 145 | def delete_reactions(token, channel_id, message_id) do 146 | (@root <> channel_id <> "/messages/" <> message_id <> "/reactions") 147 | |> Api.delete(token) 148 | end 149 | 150 | def get_channel_invites(token, channel_id) do 151 | (@root <> channel_id <> "/invites") 152 | |> Api.get(token, Api.parse_map(Invite)) 153 | end 154 | 155 | def create_channel_invite(token, channel_id, options) do 156 | (@root <> channel_id <> "/invites") 157 | |> Api.post(Api.encode(options), token, Invite) 158 | end 159 | 160 | def delete_channel_permission(token, channel_id, overwrite_id) do 161 | (@root <> channel_id <> "/permissions/" <> overwrite_id) 162 | |> Api.delete(token) 163 | end 164 | 165 | def trigger_typing(token, channel_id) do 166 | (@root <> channel_id <> "/typing") 167 | |> Api.post(token) 168 | end 169 | 170 | def get_pinned_messages(token, channel_id) do 171 | parser = fn json -> 172 | json 173 | |> (fn x -> Parser.parse!(x, %{}) end).() 174 | |> Enum.map(&Message.from_map/1) 175 | end 176 | 177 | (@root <> channel_id <> "/pins") 178 | |> Api.get(token, parser) 179 | end 180 | 181 | def add_pinned_message(token, channel_id, message_id) do 182 | (@root <> channel_id <> "/pins/" <> message_id) 183 | |> Api.put(token) 184 | end 185 | 186 | def delete_pinned_message(token, channel_id, message_id) do 187 | (@root <> channel_id <> "/pins/" <> message_id) 188 | |> Api.delete(token) 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/Discord/Endpoints/guilds.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Guilds do 2 | @moduledoc false 3 | alias Alchemy.Discord.Api 4 | alias Alchemy.{Channel, Invite, Guild, User, VoiceRegion} 5 | alias Alchemy.Guild.{GuildMember, Integration, Role} 6 | alias Alchemy.AuditLog 7 | 8 | @root "https://discord.com/api/v6/guilds/" 9 | 10 | # returns information for a current guild; cache should be preferred over this 11 | def get_guild(token, guild_id) do 12 | Api.get(@root <> guild_id, token, Guild) 13 | end 14 | 15 | def modify_guild(token, guild_id, options) do 16 | options = 17 | options 18 | |> Keyword.take([:icon, :splash]) 19 | |> Task.async_stream(fn {k, v} -> {k, Api.image_data(v)} end) 20 | |> Enum.map(fn {:ok, v} -> v end) 21 | |> Keyword.merge(options) 22 | 23 | Api.patch(@root <> guild_id, token, Api.encode(options), Guild) 24 | end 25 | 26 | def get_channels(token, guild_id) do 27 | (@root <> guild_id <> "/channels") 28 | |> Api.get(token, Api.parse_map(Channel)) 29 | end 30 | 31 | def create_channel(token, guild_id, name, options) do 32 | options = 33 | case Keyword.get(options, :voice) do 34 | true -> 35 | {_, o} = Keyword.pop(options, :voice) 36 | Keyword.put(o, :type, 2) 37 | 38 | _ -> 39 | options 40 | end 41 | |> Keyword.put(:name, name) 42 | |> Api.encode() 43 | 44 | (@root <> guild_id <> "/channels") 45 | |> Api.post(token, options, Channel) 46 | end 47 | 48 | def move_channels(token, guild_id, tuples) do 49 | channels = 50 | Stream.map(tuples, fn {id, pos} -> 51 | %{"id" => id, "position" => pos} 52 | end) 53 | |> Poison.encode!() 54 | 55 | (@root <> guild_id <> "/channels") 56 | |> Api.patch(token, channels) 57 | end 58 | 59 | def get_member(token, guild_id, user_id) do 60 | (@root <> guild_id <> "/members/" <> user_id) 61 | |> Api.get(token, GuildMember) 62 | end 63 | 64 | def get_member_list(token, guild_id, options) do 65 | query = 66 | case URI.encode_query(options) do 67 | "" -> "" 68 | q -> "?" <> q 69 | end 70 | 71 | (@root <> guild_id <> "/members" <> query) 72 | |> Api.get(token, Api.parse_map(GuildMember)) 73 | end 74 | 75 | def modify_member(token, guild_id, user_id, options) do 76 | (@root <> guild_id <> "/members/" <> user_id) 77 | |> Api.patch(token, Api.encode(options)) 78 | end 79 | 80 | def add_member(token, guild_id, user_id, options) do 81 | (@root <> guild_id <> "/members/" <> user_id) 82 | |> Api.put(token, Api.encode(options)) 83 | end 84 | 85 | def modify_nick(token, guild_id, nick) do 86 | json = ~s/{"nick": #{nick}}/ 87 | 88 | (@root <> guild_id <> "/members/@me/nick") 89 | |> Api.patch(token, json) 90 | end 91 | 92 | def add_role(token, guild_id, user_id, role_id) do 93 | (@root <> guild_id <> "/members/" <> user_id <> "/roles/" <> role_id) 94 | |> Api.put(token) 95 | end 96 | 97 | def remove_role(token, guild_id, user_id, role_id) do 98 | (@root <> guild_id <> "/members/" <> user_id <> "/roles/" <> role_id) 99 | |> Api.delete(token) 100 | end 101 | 102 | def remove_member(token, guild_id, user_id) do 103 | (@root <> guild_id <> "/members/" <> user_id) 104 | |> Api.delete(token) 105 | end 106 | 107 | def get_bans(token, guild_id) do 108 | (@root <> guild_id <> "/bans") 109 | |> Api.get(token, Api.parse_map(User)) 110 | end 111 | 112 | def create_ban(token, guild_id, user_id, days) do 113 | json = ~s/{"delete-message-days": #{days}}/ 114 | 115 | (@root <> guild_id <> "/bans/" <> user_id) 116 | |> Api.put(token, json) 117 | end 118 | 119 | def remove_ban(token, guild_id, user_id) do 120 | (@root <> guild_id <> "/bans/" <> user_id) 121 | |> Api.delete(token) 122 | end 123 | 124 | def get_roles(token, guild_id) do 125 | (@root <> guild_id <> "/roles") 126 | |> Api.get(token, [%Role{}]) 127 | end 128 | 129 | def create_role(token, guild_id, options) do 130 | (@root <> guild_id <> "/roles") 131 | |> Api.post(token, Api.encode(options), %Role{}) 132 | end 133 | 134 | def move_roles(token, guild_id, tuples) do 135 | roles = 136 | Stream.map(tuples, fn {id, pos} -> 137 | %{id: id, position: pos} 138 | end) 139 | |> Api.encode() 140 | 141 | (@root <> guild_id <> "/roles") 142 | |> Api.patch(token, roles, [%Role{}]) 143 | end 144 | 145 | def modify_role(token, guild_id, role_id, options) do 146 | (@root <> guild_id <> "/roles/" <> role_id) 147 | |> Api.patch(token, Api.encode(options), %Role{}) 148 | end 149 | 150 | def delete_role(token, guild_id, role_id) do 151 | (@root <> guild_id <> "/roles/" <> role_id) 152 | |> Api.delete(token) 153 | end 154 | 155 | def get_prune_count(token, guild_id, days) do 156 | (@root <> guild_id <> "/prune?" <> URI.encode_query(%{"days" => days})) 157 | |> Api.get(token, & &1["pruned"]) 158 | end 159 | 160 | def prune_guild(token, guild_id, days) do 161 | json = ~s/{"days": #{days}}/ 162 | 163 | (@root <> guild_id <> "/prune") 164 | |> Api.post(token, json) 165 | end 166 | 167 | def get_regions(token, guild_id) do 168 | (@root <> guild_id <> "/regions") 169 | |> Api.get(token, [%VoiceRegion{}]) 170 | end 171 | 172 | def get_invites(token, guild_id) do 173 | (@root <> guild_id <> "/invites") 174 | |> Api.get(token, Api.parse_map(Invite)) 175 | end 176 | 177 | def get_all_regions(token) do 178 | "https://discord.com/api/v6/voice/regions" 179 | |> Api.get(token, [%VoiceRegion{}]) 180 | end 181 | 182 | def get_integrations(token, guild_id) do 183 | (@root <> guild_id <> "/integrations") 184 | |> Api.get(token, Api.parse_map(Integration)) 185 | end 186 | 187 | def edit_integration(token, guild_id, integration_id, options) do 188 | (@root <> guild_id <> "/integrations/" <> integration_id) 189 | |> Api.patch(token, Api.encode(options)) 190 | end 191 | 192 | def delete_integration(token, guild_id, integration_id) do 193 | (@root <> guild_id <> "/integrations/" <> integration_id) 194 | |> Api.delete(token) 195 | end 196 | 197 | def sync_integration(token, guild_id, integration_id) do 198 | (@root <> guild_id <> "/integrations/" <> integration_id <> "/sync") 199 | |> Api.post(token) 200 | end 201 | 202 | def get_audit_log(token, guild_id, options) do 203 | (@root <> guild_id <> "/audit-log?" <> URI.encode_query(options)) 204 | |> Api.get(token, AuditLog) 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/Voice/controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Voice.Controller do 2 | @moduledoc false 3 | use GenServer 4 | require Logger 5 | alias Alchemy.Voice.Supervisor.VoiceRegistry 6 | alias Porcelain.Process, as: Proc 7 | 8 | defmodule State do 9 | @moduledoc false 10 | defstruct [ 11 | :udp, 12 | :key, 13 | :ssrc, 14 | :ip, 15 | :port, 16 | :guild_id, 17 | :player, 18 | :ws, 19 | :kill_timer, 20 | :time, 21 | listeners: MapSet.new() 22 | ] 23 | end 24 | 25 | def start_link(udp, key, ssrc, ip, port, guild_id, me) do 26 | state = %State{ 27 | udp: udp, 28 | key: key, 29 | ssrc: ssrc, 30 | ip: ip, 31 | port: port, 32 | guild_id: guild_id, 33 | ws: me, 34 | time: 0 35 | } 36 | 37 | GenServer.start_link(__MODULE__, state, name: VoiceRegistry.via({guild_id, :controller})) 38 | end 39 | 40 | def init(state) do 41 | Logger.debug("Voice Controller for #{state.guild_id} started") 42 | {:ok, state} 43 | end 44 | 45 | def handle_cast({:send_audio, data}, state) do 46 | # We need to do this because youtube streams don't cut off when they finish 47 | # playing audio, so we need to manually check and kill. 48 | unless state.kill_timer == nil do 49 | Process.cancel_timer(state.kill_timer) 50 | end 51 | 52 | timer = Process.send_after(self(), :stop_playing, 120) 53 | :gen_udp.send(state.udp, state.ip, state.port, data) 54 | {:noreply, %{state | kill_timer: timer}} 55 | end 56 | 57 | def handle_cast({:update_time, new_time}, state) do 58 | {:noreply, %{state | time: new_time}} 59 | end 60 | 61 | def handle_call({:play, path, type, options}, _, state) do 62 | self = self() 63 | 64 | if state.player != nil && Process.alive?(state.player.pid) do 65 | {:reply, {:error, "Already playing audio"}, state} 66 | else 67 | player = 68 | Task.async(fn -> 69 | new_time = 70 | run_player(path, type, options, self, %{ 71 | ssrc: state.ssrc, 72 | key: state.key, 73 | ws: state.ws, 74 | time: state.time 75 | }) 76 | 77 | GenServer.cast(self, {:update_time, new_time}) 78 | end) 79 | 80 | {:reply, :ok, %{state | player: player}} 81 | end 82 | end 83 | 84 | def handle_call(:stop_playing, _, state) do 85 | new = 86 | case state.player do 87 | nil -> state 88 | _ -> stop_playing(state) 89 | end 90 | 91 | {:reply, :ok, new} 92 | end 93 | 94 | def handle_call(:add_listener, {pid, _}, state) do 95 | {:reply, :ok, Map.update!(state, :listeners, &MapSet.put(&1, pid))} 96 | end 97 | 98 | def handle_info(:stop_playing, state) do 99 | {:noreply, stop_playing(state)} 100 | end 101 | 102 | # ignore down messages from the task 103 | def handle_info(_, state) do 104 | {:noreply, state} 105 | end 106 | 107 | defp stop_playing(state) do 108 | Task.shutdown(state.player) 109 | 110 | MapSet.to_list(state.listeners) 111 | |> Enum.each(&send(&1, {:audio_stopped, state.guild_id})) 112 | 113 | %{state | listeners: MapSet.new()} 114 | end 115 | 116 | ## Audio stuff ## 117 | 118 | defp header(sequence, time, ssrc) do 119 | <<0x80, 0x78, sequence::size(16), time::size(32), ssrc::size(32)>> 120 | end 121 | 122 | defp mk_stream(file_path, options) do 123 | volume = (options[:vol] || 100) / 100 124 | 125 | %Proc{out: audio_stream} = 126 | Porcelain.spawn( 127 | Application.fetch_env!(:alchemy, :ffmpeg_path), 128 | [ 129 | "-hide_banner", 130 | "-loglevel", 131 | "quiet", 132 | "-i", 133 | "#{file_path}", 134 | "-f", 135 | "data", 136 | "-map", 137 | "0:a", 138 | "-ar", 139 | "48k", 140 | "-ac", 141 | "2", 142 | "-af", 143 | "volume=#{volume}", 144 | "-acodec", 145 | "libopus", 146 | "-b:a", 147 | "128k", 148 | "pipe:1" 149 | ], 150 | out: :stream 151 | ) 152 | 153 | audio_stream 154 | end 155 | 156 | defp url_stream(url, options) do 157 | %Proc{out: youtube} = 158 | Porcelain.spawn( 159 | Application.fetch_env!(:alchemy, :youtube_dl_path), 160 | ["-q", "-f", "bestaudio", "-o", "-", url], 161 | out: :stream 162 | ) 163 | 164 | io_data_stream(youtube, options) 165 | end 166 | 167 | defp io_data_stream(data, options) do 168 | volume = (options[:vol] || 100) / 100 169 | opts = [in: data, out: :stream] 170 | 171 | %Proc{out: audio_stream} = 172 | Porcelain.spawn( 173 | Application.fetch_env!(:alchemy, :ffmpeg_path), 174 | [ 175 | "-hide_banner", 176 | "-loglevel", 177 | "quiet", 178 | "-i", 179 | "pipe:0", 180 | "-f", 181 | "data", 182 | "-map", 183 | "0:a", 184 | "-ar", 185 | "48k", 186 | "-ac", 187 | "2", 188 | "-af", 189 | "volume=#{volume}", 190 | "-acodec", 191 | "libopus", 192 | "-b:a", 193 | "128k", 194 | "pipe:1" 195 | ], 196 | opts 197 | ) 198 | 199 | audio_stream 200 | end 201 | 202 | defp run_player(path, type, options, parent, state) do 203 | send(state.ws, {:speaking, true}) 204 | 205 | stream = 206 | case type do 207 | :file -> mk_stream(path, options) 208 | :url -> url_stream(path, options) 209 | :iodata -> io_data_stream(path, options) 210 | end 211 | 212 | {seq, time, _} = 213 | stream 214 | |> Enum.reduce({0, state.time, nil}, fn packet, {seq, time, elapsed} -> 215 | packet = mk_audio(packet, seq, time, state) 216 | GenServer.cast(parent, {:send_audio, packet}) 217 | # putting the elapsed time directly in the accumulator makes it incorrect 218 | elapsed = elapsed || :os.system_time(:milli_seconds) 219 | Process.sleep(do_sleep(elapsed)) 220 | {seq + 1, time + 960, elapsed + 20} 221 | end) 222 | 223 | # We must send 5 frames of silence to end opus interpolation 224 | {_seq, new_time} = 225 | Enum.reduce(1..5, {seq, time}, fn _, {seq, time} -> 226 | GenServer.cast(parent, {:send_audio, <<0xF8, 0xFF, 0xFE>>}) 227 | Process.sleep(20) 228 | {seq + 1, time + 960} 229 | end) 230 | 231 | send(state.ws, {:speaking, false}) 232 | new_time 233 | end 234 | 235 | defp mk_audio(packet, seq, time, state) do 236 | header = header(seq, time, state.ssrc) 237 | nonce = header <> <<0::size(96)>> 238 | header <> Kcl.secretbox(packet, nonce, state.key) 239 | end 240 | 241 | # this also takes care of adjusting for sleeps taking too long 242 | defp do_sleep(elapsed, delay \\ 20) do 243 | max(0, elapsed - :os.system_time(:milli_seconds) + delay) 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /lib/Structs/permissions.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Permissions do 2 | @moduledoc """ 3 | This module contains useful functions for working for the permission 4 | bitsets discord provides. 5 | 6 | To combine the permissions of an overwrite 7 | with the permissions of a role, the bitwise `|||` can be used. 8 | 9 | ## Example Usage 10 | ```elixir 11 | Cogs.def perms(role_name) do 12 | {:ok, guild} = Cogs.guild() 13 | role = hd Enum.filter(guild.roles, & &1.name == role_name) 14 | Cogs.say(inspect Permissions.to_list(role.permission)) 15 | end 16 | ``` 17 | This simple command prints out the list of permissions a role has avaiable. 18 | 19 | ## Permission List 20 | - `:create_instant_invite` 21 | Allows the creation of instant invites. 22 | - `:kick_members` 23 | Allows the kicking of members. 24 | - `:ban_members` 25 | Allows the banning of members. 26 | - `:administrator` 27 | Allows all permissions, and bypasses channel overwrites. 28 | - `:manage_channels` 29 | Allows management and editing of channels. 30 | - `:manage_guild` 31 | Allows management and editing of the guild. 32 | - `:add_reactions` 33 | Allows adding reactions to message. 34 | - `:view_audit_log` 35 | Allows for viewing of audit logs. 36 | - `:read_messages` 37 | Allows reading messages in a channel. Without this, the user won't 38 | even see the channel. 39 | - `:send_messages` 40 | Allows sending messages in a channel. 41 | - `:send_tts_messages` 42 | Allows sending text to speech messages. 43 | - `:manage_messages` 44 | Allows for deletion of other user messages. 45 | - `:embed_links` 46 | Links sent with this permission will be embedded automatically 47 | - `:attach_files` 48 | Allows the user to send files, and images 49 | - `:read_message_history` 50 | Allows the user to read the message history of a channel 51 | - `:mention_everyone` 52 | Allows the user to mention the special `@everyone` and `@here` tags 53 | - `:use_external_emojis` 54 | Allows the user to use emojis from other servers. 55 | - `:connect` 56 | Allows the user to connect to a voice channel. 57 | - `:speak` 58 | Allows the user to speak in a voice channel. 59 | - `:mute_members` 60 | Allows the user to mute members in a voice channel. 61 | - `:deafen_members` 62 | Allows the user to deafen members in a voice channel. 63 | - `:move_members` 64 | Allows the user to move members between voice channels. 65 | - `:use_vad` 66 | Allows the user to use voice activity detection in a voice channel 67 | - `:change_nickname` 68 | Allows the user to change his own nickname. 69 | - `:manage_nicknames` 70 | Allows for modification of other user nicknames. 71 | - `:manage_roles` 72 | Allows for management and editing of roles. 73 | - `:manage_webhooks` 74 | Allows for management and editing of webhooks. 75 | - `:manage_emojis` 76 | Allows for management and editing of emojis. 77 | """ 78 | alias Alchemy.Guild 79 | use Bitwise 80 | 81 | @perms [ 82 | :create_instant_invite, 83 | :kick_members, 84 | :ban_members, 85 | :administrator, 86 | :manage_channels, 87 | :manage_guild, 88 | :add_reactions, 89 | :view_audit_log, 90 | :read_messages, 91 | :send_messages, 92 | :send_tts_messages, 93 | :manage_messages, 94 | :embed_links, 95 | :attach_files, 96 | :read_message_history, 97 | :mention_everyone, 98 | :use_external_emojis, 99 | :connect, 100 | :speak, 101 | :mute_members, 102 | :deafen_members, 103 | :move_members, 104 | :use_vad, 105 | :change_nickname, 106 | :manage_nicknames, 107 | :manage_roles, 108 | :manage_webhooks, 109 | :manage_emojis 110 | ] 111 | 112 | @nil_flags [0x00000100, 0x00000200, 0x00080000] 113 | 114 | @perm_map Stream.zip(@perms, Enum.map(0..30, &(1 <<< &1)) -- @nil_flags) 115 | |> Enum.into(%{}) 116 | 117 | @type permission :: atom 118 | 119 | @doc """ 120 | Converts a permission bitset into a legible list of atoms. This is the reverse operation of `to_bitset/1`. 121 | 122 | For checking if a specific permission is in that list, use `contains?/2` 123 | instead. 124 | ## Examples 125 | ```elixir 126 | permissions = Permissions.to_list(role.permissions) 127 | ``` 128 | """ 129 | @spec to_list(Integer) :: [permission] 130 | def to_list(bitset) do 131 | @perm_map 132 | |> Enum.reduce([], fn 133 | {k, v}, acc when (v &&& bitset) != 0 -> [k | acc] 134 | _, acc -> acc 135 | end) 136 | end 137 | 138 | @doc """ 139 | Converts a list of permission atoms into a bitset. This is the reverse operation of `to_list/1`. 140 | 141 | ## Examples 142 | ```elixir 143 | bitset = Permissions.to_bitset([:send_messages, :speak]) 144 | ``` 145 | """ 146 | @spec to_bitset([permission]) :: Integer 147 | def to_bitset(list) do 148 | @perm_map 149 | |> Enum.reduce( 150 | 0, 151 | fn {k, v}, acc -> 152 | if Enum.member?(list, k), do: acc ||| v, else: acc 153 | end 154 | ) 155 | end 156 | 157 | @doc """ 158 | Checks for the presence of a permission in a permission bitset. 159 | 160 | This should be preferred over using `:perm in Permissions.to_list(x)` 161 | because this works directly using bitwise operations, and is much 162 | more efficient then going through the permissions. 163 | ## Examples 164 | ```elixir 165 | Permissions.contains?(role.permissions, :manage_roles) 166 | ``` 167 | """ 168 | @spec contains?(Integer, permission) :: Boolean 169 | def contains?(bitset, permission) when permission in @perms do 170 | (bitset &&& @perm_map[:administrator]) != 0 or 171 | (bitset &&& @perm_map[permission]) != 0 172 | end 173 | 174 | def contains?(_, permission) do 175 | raise ArgumentError, 176 | message: 177 | "#{permission} is not a valid permisson." <> 178 | "See documentation for a list of permissions." 179 | end 180 | 181 | @doc """ 182 | Gets the actual permissions of a member in a guild channel. 183 | 184 | This will error if the channel_id passed isn't in the guild. 185 | This will mismatch if the wrong structs are passed, or if the guild 186 | doesn't have a channel field. 187 | """ 188 | def channel_permissions(%Guild.GuildMember{} = member, %Guild{channels: cs} = guild, channel_id) do 189 | highest_role = Guild.highest_role(guild, member) 190 | channel = Enum.find(cs, &(&1.id == channel_id)) 191 | 192 | case channel do 193 | nil -> 194 | {:error, "#{channel_id} is not a channel in this guild"} 195 | 196 | _ -> 197 | {:ok, 198 | (highest_role.permissions ||| channel.overwrite.allow) &&& 199 | ~~~channel.overwrite.deny} 200 | end 201 | end 202 | 203 | @doc """ 204 | Banged version of `channel_permissions/3` 205 | """ 206 | def channel_permissions!(%Guild.GuildMember{} = member, %Guild{} = guild, channel_id) do 207 | case channel_permissions(member, guild, channel_id) do 208 | {:error, s} -> raise ArgumentError, message: s 209 | {:ok, perms} -> perms 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /lib/Discord/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Discord.Events do 2 | # This module contains the protocols 3 | @moduledoc false 4 | # for updating the cache based on the events received from discord. 5 | # This module is then used by EventStage.Cache 6 | alias Alchemy.{Channel, Channel.DMChannel, Guild.Emoji, Guild, Message, User, VoiceState} 7 | alias Alchemy.Guild.{GuildMember, Presence, Role} 8 | alias Alchemy.Cache.{Channels, Guilds, PrivChannels} 9 | import Alchemy.Structs 10 | 11 | # A direct message was started with the bot 12 | # type 1 == DM 13 | # https://discord.com/developers/docs/resources/channel#channel-object-channel-types 14 | def handle("CHANNEL_CREATE", %{"type" => 1} = dm_channel) do 15 | PrivChannels.add_channel(dm_channel) 16 | struct = to_struct(dm_channel, DMChannel) 17 | {:dm_channel_create, [struct]} 18 | end 19 | 20 | def handle("CHANNEL_CREATE", %{"guild_id" => guild_id} = channel) do 21 | struct = Channel.from_map(channel) 22 | Guilds.add_channel(guild_id, channel) 23 | 24 | {:channel_create, [struct]} 25 | end 26 | 27 | def handle("CHANNEL_UPDATE", %{"id" => channel_id} = channel) do 28 | with {:ok, guild_id} <- Channels.lookup(channel_id) do 29 | Guilds.update_channel(guild_id, channel) 30 | end 31 | 32 | {:channel_update, [Channel.from_map(channel)]} 33 | end 34 | 35 | def handle("CHANNEL_DELETE", %{"type" => 1} = dm_channel) do 36 | PrivChannels.remove_channel(dm_channel["id"]) 37 | {:dm_channel_delete, [to_struct(dm_channel, DMChannel)]} 38 | end 39 | 40 | def handle("CHANNEL_DELETE", %{"id" => channel_id} = channel) do 41 | with {:ok, guild_id} <- Channels.lookup(channel_id) do 42 | Guilds.remove_channel(guild_id, channel_id) 43 | end 44 | 45 | Channels.remove_channel(channel_id) 46 | {:channel_delete, [Channel.from_map(channel)]} 47 | end 48 | 49 | # The Cache manager is tasked of notifying, if, and only if this guild is new, 50 | # and not in the unavailable guilds loaded before 51 | def handle("GUILD_CREATE", guild) do 52 | Guilds.add_guild(guild) 53 | end 54 | 55 | def handle("GUILD_UPDATE", guild) do 56 | guild = 57 | Guilds.update_guild(guild) 58 | |> Guilds.de_index() 59 | |> Guild.from_map() 60 | 61 | {:guild_update, [guild]} 62 | end 63 | 64 | # The Cache is responsible for notifications in this case 65 | def handle("GUILD_DELETE", guild) do 66 | Guilds.remove_guild(guild) 67 | end 68 | 69 | def handle("GUILD_BAN_ADD", %{"guild_id" => id} = user) do 70 | {:guild_ban, [to_struct(user, User), id]} 71 | end 72 | 73 | def handle("GUILD_BAN_REMOVE", %{"guild_id" => id} = user) do 74 | {:guild_unban, [to_struct(user, User), id]} 75 | end 76 | 77 | def handle("GUILD_EMOJIS_UPDATE", data) do 78 | Guilds.update_emojis(data) 79 | {:emoji_update, [map_struct(data["emojis"], Emoji), data["guild_id"]]} 80 | end 81 | 82 | def handle("GUILD_INTEGRATIONS_UPDATE", %{"guild_id" => id}) do 83 | {:integrations_update, [id]} 84 | end 85 | 86 | def handle("GUILD_MEMBER_ADD", %{"guild_id" => id}) do 87 | {:member_join, [id]} 88 | end 89 | 90 | def handle("GUILD_MEMBERS_CHUNK", %{"guild_id" => id, "members" => m}) do 91 | Guilds.add_members(id, m) 92 | {:member_chunk, [id, Enum.map(m, &GuildMember.from_map/1)]} 93 | end 94 | 95 | def handle("GUILD_MEMBER_REMOVE", %{"guild_id" => id, "user" => user}) do 96 | Guilds.remove_member(id, user) 97 | {:member_leave, [to_struct(user, User), id]} 98 | end 99 | 100 | def handle("GUILD_MEMBER_UPDATE", %{"guild_id" => id} = data) do 101 | # This key would get popped implicitly later, but I'd rather do it clearly here 102 | Guilds.update_member(id, Map.delete(data, "guild_id")) 103 | {:member_update, [GuildMember.from_map(data), id]} 104 | end 105 | 106 | def handle("GUILD_ROLE_CREATE", %{"guild_id" => id, "role" => role}) do 107 | Guilds.add_role(id, role) 108 | {:role_create, [to_struct(role, Role), id]} 109 | end 110 | 111 | def handle("GUILD_ROLE_UPDATE", %{"guild_id" => id, "role" => new_role = %{"id" => role_id}}) do 112 | guild_result = Guilds.safe_call(id, {:section, "roles"}) 113 | 114 | old_role = 115 | with {:ok, guild} <- guild_result, 116 | role when not is_nil(role) <- guild[role_id] do 117 | to_struct(role, Role) 118 | else 119 | _ -> nil 120 | end 121 | 122 | Guilds.update_role(id, new_role) 123 | new_role = to_struct(new_role, Role) 124 | {:role_update, [old_role, new_role, id]} 125 | end 126 | 127 | def handle("GUILD_ROLE_DELETE", %{"guild_id" => guild_id, "role_id" => id}) do 128 | Guilds.remove_role(guild_id, id) 129 | {:role_delete, [id, guild_id]} 130 | end 131 | 132 | def handle("MESSAGE_CREATE", message) do 133 | struct = Message.from_map(message) 134 | {:message_create, [struct]} 135 | end 136 | 137 | def handle("MESSAGE_UPDATE", message) do 138 | {:message_update, [Message.from_map(message)]} 139 | end 140 | 141 | def handle("MESSAGE_DELETE", %{"id" => msg_id, "channel_id" => chan_id}) do 142 | {:message_delete, [msg_id, chan_id]} 143 | end 144 | 145 | def handle("MESSAGE_DELETE_BULK", %{"ids" => ids, "channel_id" => chan_id}) do 146 | {:message_delete_bulk, [ids, chan_id]} 147 | end 148 | 149 | def handle("MESSAGE_REACTION_ADD", %{ 150 | "user_id" => user_id, 151 | "channel_id" => channel_id, 152 | "message_id" => message_id, 153 | "emoji" => emoji 154 | }) do 155 | {:message_reaction_add, [user_id, channel_id, message_id, emoji]} 156 | end 157 | 158 | def handle("MESSAGE_REACTION_REMOVE", %{ 159 | "user_id" => user_id, 160 | "channel_id" => channel_id, 161 | "message_id" => message_id, 162 | "emoji" => emoji 163 | }) do 164 | {:message_reaction_remove, [user_id, channel_id, message_id, emoji]} 165 | end 166 | 167 | def handle("MESSAGE_REACTION_REMOVE_ALL", %{ 168 | "channel_id" => channel_id, 169 | "message_id" => message_id 170 | }) do 171 | {:message_reaction_remove_all, [channel_id, message_id]} 172 | end 173 | 174 | def handle("PRESENCE_UPDATE", %{"guild_id" => _id} = presence) do 175 | Guilds.update_presence(presence) 176 | {:presence_update, [Presence.from_map(presence)]} 177 | end 178 | 179 | def handle("PRESENCE_UPDATE", presence) do 180 | {:presence_update, [Presence.from_map(presence)]} 181 | end 182 | 183 | def handle("READY", payload) do 184 | {:ready, payload["shard"]} 185 | end 186 | 187 | def handle("TYPING_START", data) do 188 | chan_id = data["channel_id"] 189 | user_id = data["user_id"] 190 | timestamp = data["timestamp"] 191 | {:typing_start, [user_id, chan_id, timestamp]} 192 | end 193 | 194 | def handle("USER_SETTINGS_UPDATE", %{"username" => name, "avatar" => avatar}) do 195 | {:user_settings_update, [name, avatar]} 196 | end 197 | 198 | def handle("USER_UPDATE", user) do 199 | {:user_update, [to_struct(user, User)]} 200 | end 201 | 202 | def handle("VOICE_STATE_UPDATE", voice) do 203 | Guilds.update_voice_state(voice) 204 | {:voice_state_update, [to_struct(voice, VoiceState)]} 205 | end 206 | 207 | def handle(_, _) do 208 | {:unkown, []} 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/Cache/guilds.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cache.Guilds do 2 | # The template GenServer for guilds started dynamically 3 | @moduledoc false 4 | # by the supervisor in the submodule below 5 | use GenServer 6 | alias Alchemy.Cache.Guilds.GuildSupervisor 7 | alias Alchemy.Cache.Channels 8 | alias Alchemy.Guild 9 | import Alchemy.Cache.Utility 10 | 11 | defmodule GuildSupervisor do 12 | @moduledoc false 13 | # acts as a dynamic supervisor for the surrounding GenServer 14 | use Supervisor 15 | alias Alchemy.Cache.Guilds 16 | 17 | def start_link do 18 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 19 | end 20 | 21 | def init(:ok) do 22 | children = [ 23 | worker(Guilds, []) 24 | ] 25 | 26 | supervise(children, strategy: :simple_one_for_one) 27 | end 28 | end 29 | 30 | defp via_guilds(id) do 31 | {:via, Registry, {:guilds, id}} 32 | end 33 | 34 | def safe_call(id, msg) do 35 | if Registry.lookup(:guilds, id) != [] do 36 | {:ok, call(id, msg)} 37 | else 38 | {:error, :no_guild} 39 | end 40 | end 41 | 42 | def call(id, msg) do 43 | GenServer.call(via_guilds(id), msg) 44 | end 45 | 46 | def start_link(%{"id" => id} = guild) do 47 | GenServer.start_link(__MODULE__, guild, name: via_guilds(id)) 48 | end 49 | 50 | @guild_indexes [ 51 | {["members"], ["user", "id"]}, 52 | {["roles"], ["id"]}, 53 | {["presences"], ["user", "id"]}, 54 | {["voice_states"], ["user_id"]}, 55 | {["emojis"], ["id"]}, 56 | {["channels"], ["id"]} 57 | ] 58 | # This changes the inner arrays to become maps, for easier access later 59 | defp guild_index(%{"unavailable" => true} = guild) do 60 | guild 61 | end 62 | 63 | defp guild_index(guild) do 64 | inner_index(guild, @guild_indexes) 65 | end 66 | 67 | # This version will check for null keys. Useful in the update event 68 | defp safe_guild_index(guild) do 69 | safe_inner_index(guild, @guild_indexes) 70 | end 71 | 72 | def de_index(guild) do 73 | keys = ["members", "roles", "presences", "voice_states", "emojis", "channels"] 74 | 75 | Enum.reduce(keys, guild, fn k, g -> 76 | update_in(g[k], &Map.values/1) 77 | end) 78 | end 79 | 80 | defp start_guild(guild) do 81 | Supervisor.start_child(GuildSupervisor, [guild]) 82 | {:unavailable_guild, []} 83 | end 84 | 85 | # The guild is either new, or partial info for an existing guild 86 | def add_guild(%{"unavailable" => true} = guild) do 87 | start_guild(guild) 88 | end 89 | 90 | def add_guild(%{"id" => id} = guild) do 91 | Channels.add_channels(guild["channels"], id) 92 | 93 | case Registry.lookup(:guilds, id) do 94 | [] -> 95 | start_guild(guild_index(guild)) 96 | {:guild_create, [Guild.from_map(guild)]} 97 | 98 | [{pid, _}] -> 99 | GenServer.call(pid, {:merge, guild_index(guild)}) 100 | {:guild_online, [Guild.from_map(guild)]} 101 | end 102 | end 103 | 104 | def remove_guild(%{"id" => id, "unavailable" => true}) do 105 | call(id, :set_unavailable) 106 | end 107 | 108 | def remove_guild(%{"id" => id}) do 109 | Supervisor.terminate_child(GuildSupervisor, via_guilds(id)) 110 | {:guild_delete, [id]} 111 | end 112 | 113 | # Because this event is usually partial, we use safe_guild_index 114 | def update_guild(%{"id" => id} = guild) do 115 | Channels.add_channels(guild["channels"], id) 116 | call(id, {:merge, safe_guild_index(guild)}) 117 | end 118 | 119 | def update_emojis(%{"guild_id" => id, "emojis" => emojis}) do 120 | call(id, {:replace, "emojis", index(emojis)}) 121 | end 122 | 123 | def update_member(guild_id, %{"user" => %{"id" => id}} = member) do 124 | call(guild_id, {:update, ["members", id], member}) 125 | end 126 | 127 | def remove_member(guild_id, %{"id" => id}) do 128 | call(guild_id, {:pop, "members", id}) 129 | end 130 | 131 | def add_role(guild_id, %{"id" => id} = role) do 132 | call(guild_id, {:put, "roles", id, role}) 133 | end 134 | 135 | def update_role(guild_id, role) do 136 | add_role(guild_id, role) 137 | end 138 | 139 | def remove_role(guild_id, role_id) do 140 | call(guild_id, {:pop, "roles", role_id}) 141 | end 142 | 143 | def add_channel(guild_id, %{"id" => id} = channel) do 144 | # assume has guild_id, otherwise we have no idea where it belongs 145 | Channels.add_channels([channel], channel["guild_id"]) 146 | call(guild_id, {:put, "channels", id, channel}) 147 | end 148 | 149 | def update_channel(guild_id, channel) do 150 | add_channel(guild_id, channel) 151 | end 152 | 153 | def remove_channel(guild_id, channel_id) do 154 | Channels.remove_channel(channel_id) 155 | call(guild_id, {:pop, "channels", channel_id}) 156 | end 157 | 158 | def update_presence(presence) do 159 | guild_id = presence["guild_id"] 160 | pres_id = presence["user"]["id"] 161 | call(guild_id, {:update_presence, pres_id, presence}) 162 | end 163 | 164 | def update_voice_state(%{"user_id" => id, "guild_id" => guild_id} = voice) do 165 | call(guild_id, {:put, "voice_states", id, voice}) 166 | end 167 | 168 | def add_members(guild_id, members) do 169 | call(guild_id, {:update, ["members"], index(members, ["user", "id"])}) 170 | end 171 | 172 | ### Server ### 173 | 174 | # This call returns the full info, because the partial info from the event 175 | # isn't usually very useful. 176 | def handle_call({:merge, new_info}, _, state) do 177 | new = Map.merge(state, new_info) 178 | {:reply, new, new} 179 | end 180 | 181 | def handle_call(:show, _, state) do 182 | {:reply, state, state} 183 | end 184 | 185 | def handle_call({:section, key}, _, state) do 186 | {:reply, state[key], state} 187 | end 188 | 189 | # as opposed to the above call, this also returns the id of the guild 190 | def handle_call({:section_with_id, key}, _, state) do 191 | {:reply, {state["id"], state[key]}, state} 192 | end 193 | 194 | def handle_call(_, _, %{"unavailable" => true} = state) do 195 | {:reply, :ok, state} 196 | end 197 | 198 | def handle_call({:replace, section, data}, _, state) do 199 | {:reply, :ok, %{state | section => data}} 200 | end 201 | 202 | def handle_call({:put, section, key, node}, _, state) do 203 | {:reply, :ok, put_in(state, [section, key], node)} 204 | end 205 | 206 | def handle_call({:update, section, data}, _, state) do 207 | new = 208 | update_in(state, section, fn 209 | # Need to figure out why members sometimes become nil. 210 | nil -> data 211 | there -> Map.merge(there, data) 212 | end) 213 | 214 | {:reply, :ok, new} 215 | end 216 | 217 | # this event is special enough to warrant its own special handling 218 | def handle_call({:update_presence, key, data}, _, state) do 219 | new = 220 | if Map.has_key?(state["presences"], key) do 221 | update_in(state, ["presences", key], fn presence -> 222 | case {data, presence} do 223 | {%{"user" => new}, %{"user" => old}} -> 224 | new_user = Map.merge(new, old) 225 | 226 | presence 227 | |> Map.merge(data) 228 | |> Map.put("user", new_user) 229 | 230 | _ -> 231 | Map.merge(presence, data) 232 | end 233 | end) 234 | else 235 | put_in(state, ["presences", key], data) 236 | end 237 | 238 | {:reply, :ok, new} 239 | end 240 | 241 | def handle_call({:pop, section, key}, _, state) do 242 | {_, new} = pop_in(state, [section, key]) 243 | {:reply, :ok, new} 244 | end 245 | 246 | def handle_call(:set_unavailable, _, guild) do 247 | {:reply, :ok, %{guild | "unavailable" => true}} 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /lib/Structs/audit_log.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.AuditLog do 2 | @moduledoc """ 3 | This module contains functions and types related to audit logs. 4 | """ 5 | alias Alchemy.{Guild.Role, OverWrite, User, Webhook} 6 | alias Alchemy.Discord.Guilds 7 | import Alchemy.Discord.RateManager, only: [send_req: 2] 8 | import Alchemy.Structs 9 | 10 | @type snowflake :: String.t() 11 | 12 | @typedoc """ 13 | Represents the Audit Log information of a guild. 14 | 15 | - `webhooks` 16 | List of webhooks found in the Audit Log. 17 | - `user` 18 | List of users found in the Audit Log. 19 | - `audit_log_entries` 20 | List of entries in the Audit Log. 21 | """ 22 | @type t :: %__MODULE__{ 23 | webhooks: Alchemy.Webhook.t(), 24 | users: Alchemy.User.t(), 25 | audit_log_entries: [entry] 26 | } 27 | defstruct [:webhooks, :users, :audit_log_entries] 28 | 29 | @doc false 30 | def from_map(map) do 31 | map 32 | |> field_map("webhooks", &map_struct(&1, Webhook)) 33 | |> field_map("users", &map_struct(&1, User)) 34 | |> field_map( 35 | "audit_log_entries", 36 | &Enum.map(&1, fn x -> 37 | __MODULE__.Entry.from_map(x) 38 | end) 39 | ) 40 | end 41 | 42 | @typedoc """ 43 | An enumeration of action types. 44 | """ 45 | @type action :: 46 | :guild_update 47 | | :channel_create 48 | | :channel_update 49 | | :channel_delete 50 | | :channel_overwrite_create 51 | | :channel_overwrite_update 52 | | :channel_overwrite_delete 53 | | :member_kick 54 | | :member_prune 55 | | :member_ban_add 56 | | :member_ban_remove 57 | | :member_update 58 | | :member_role_update 59 | | :role_create 60 | | :role_update 61 | | :role_delete 62 | | :invite_create 63 | | :invite_update 64 | | :invite_delete 65 | | :webhook_create 66 | | :webhook_update 67 | | :webhook_delete 68 | | :emoji_create 69 | | :emoji_update 70 | | :message_delete 71 | 72 | @typedoc """ 73 | Additional information fields in an audit log based on `action_type`. 74 | 75 | `:member_prune` -> `[:delete_member_days, :members_removed]` 76 | `:message_delete` -> `[:channel_id, :count]` 77 | `:channel_overwrite_create | delete | update` -> [:id, :type, :role_name] 78 | """ 79 | @type options :: %{ 80 | optional(:delete_member_days) => String.t(), 81 | optional(:members_removed) => String.t(), 82 | optional(:channel_id) => snowflake, 83 | optional(:count) => integer, 84 | optional(:id) => snowflake, 85 | optional(:type) => String.t(), 86 | optional(:role_name) => String.t() 87 | } 88 | 89 | @typedoc """ 90 | An entry in an audit log. 91 | 92 | - `target_id` 93 | The id of the affected entity. 94 | - `changes` 95 | The changes made to the `target_id`. 96 | - `user_id` 97 | The user who made the changes. 98 | - `id` 99 | The id of the entry 100 | - `action_type` 101 | The type of action that occurred 102 | - `options` 103 | Additional map of information for certain action types. 104 | - `reason` 105 | The reason for the change 106 | """ 107 | @type entry :: %__MODULE__.Entry{ 108 | target_id: String.t(), 109 | changes: [change], 110 | user_id: snowflake, 111 | id: snowflake, 112 | action_type: action, 113 | options: options 114 | } 115 | 116 | defmodule Entry do 117 | @moduledoc false 118 | import Alchemy.Structs 119 | 120 | defstruct [:target_id, :changes, :user_id, :id, :action_type, :options, :reason] 121 | 122 | @audit_log_events %{ 123 | 1 => :guild_update, 124 | 10 => :channel_create, 125 | 11 => :channel_update, 126 | 12 => :channel_delete, 127 | 13 => :channel_overwrite_create, 128 | 14 => :channel_overwrite_update, 129 | 15 => :channel_overwrite_delete, 130 | 20 => :member_kick, 131 | 21 => :member_prune, 132 | 22 => :member_ban_add, 133 | 23 => :member_ban_remove, 134 | 24 => :member_update, 135 | 25 => :member_role_update, 136 | 30 => :role_create, 137 | 31 => :role_update, 138 | 32 => :role_delete, 139 | 40 => :invite_create, 140 | 41 => :invite_update, 141 | 42 => :invite_delete, 142 | 50 => :webhook_create, 143 | 51 => :webhook_update, 144 | 52 => :webhook_delete, 145 | 60 => :emoji_create, 146 | 61 => :emoji_update, 147 | 72 => :message_delete 148 | } 149 | 150 | @events_to_int for {k, v} <- @audit_log_events, into: %{}, do: {v, k} 151 | 152 | def action_to_int(k) do 153 | @events_to_int[k] 154 | end 155 | 156 | def from_map(map) do 157 | action_type = Map.get(@audit_log_events, map["action_type"]) 158 | 159 | options = 160 | for {k, v} <- map["options"], into: %{} do 161 | # this is safe, because there's a set amount of keys. 162 | {String.to_atom(k), v} 163 | end 164 | |> Map.get_and_update(:count, fn 165 | nil -> 166 | :pop 167 | 168 | x -> 169 | {a, _} = Integer.parse(x) 170 | {x, a} 171 | end) 172 | 173 | map 174 | |> field_map("action_type", fn _ -> action_type end) 175 | |> field_map("options", fn _ -> options end) 176 | |> field_map("changes", &map_struct(&1, Alchemy.AuditLog.Change)) 177 | |> to_struct(__MODULE__) 178 | end 179 | end 180 | 181 | @typedoc """ 182 | The type of an audit log change. 183 | 184 | - `new_value` 185 | The new value after the change. 186 | - `old_value` 187 | The value prior to the change. 188 | - `key` 189 | The type of change that occurred. This also dictates the type of 190 | `new_value` and `old_value` 191 | 192 | [more information on this relation](https://discord.com/developers/docs/resources/audit-log#audit-log-change-object-audit-log-change-key) 193 | """ 194 | @type change :: %__MODULE__.Change{ 195 | new_value: any, 196 | old_value: any, 197 | key: String.t() 198 | } 199 | 200 | defmodule Change do 201 | @moduledoc false 202 | import Alchemy.Structs 203 | 204 | defstruct [:new_value, :old_value, :key] 205 | 206 | def from_map(map) do 207 | key_change = 208 | case map["key"] do 209 | "$add" -> &map_struct(&1, Role) 210 | "$remove" -> &map_struct(&1, Role) 211 | "permission_overwrites" -> &struct(OverWrite, &1) 212 | _ -> & &1 213 | end 214 | 215 | map 216 | |> field_map("key", key_change) 217 | |> to_struct(__MODULE__) 218 | end 219 | end 220 | 221 | @doc """ 222 | Returns an audit log entry for a guild. 223 | 224 | Requires `:view_audit_log` permission. 225 | 226 | ## Options 227 | - `user_id` 228 | Filters the log for a user id. 229 | - `action_type` 230 | The type of audit log event 231 | - `before` 232 | Filter the log before a certain entry id. 233 | - `limit` 234 | How many entries are returned (default 50, between 1 and 100). 235 | """ 236 | @spec get_guild_log(snowflake, 237 | user_id: snowflake, 238 | action_type: action, 239 | before: snowflake, 240 | limit: integer 241 | ) :: {:ok, __MODULE__.t()} | {:error, term} 242 | def get_guild_log(guild, options \\ []) do 243 | options = 244 | Keyword.get_and_update(options, :action_type, fn 245 | nil -> 246 | :pop 247 | 248 | x -> 249 | {x, __MODULE__.Entry.action_to_int(x)} 250 | end) 251 | 252 | {Guilds, :get_audit_log, [guild, options]} 253 | |> send_req("/guilds/#{guild}/audit-log") 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/Structs/Channels/channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Channel do 2 | require Logger 3 | 4 | alias Alchemy.OverWrite 5 | 6 | alias Alchemy.Channel.{ 7 | Invite, 8 | Invite.InviteChannel, 9 | Invite.InviteGuild, 10 | TextChannel, 11 | ChannelCategory, 12 | VoiceChannel, 13 | DMChannel, 14 | GroupDMChannel, 15 | NewsChannel, 16 | StoreChannel, 17 | StageVoiceChannel 18 | } 19 | 20 | alias Alchemy.User 21 | import Alchemy.Structs 22 | 23 | @moduledoc """ 24 | This module contains useful functions for operating on `Channels`. 25 | """ 26 | 27 | @typedoc """ 28 | Represents a permission OverWrite object 29 | 30 | - `id` 31 | 32 | role or user id 33 | - `type` 34 | 35 | either "role", or "member" 36 | - `allow` 37 | 38 | the bit set of that permission 39 | - `deny` 40 | 41 | the bit set of that permission 42 | """ 43 | @type overwrite :: %OverWrite{ 44 | id: String.t(), 45 | type: String.t(), 46 | allow: Integer, 47 | deny: Integer 48 | } 49 | 50 | @type snowflake :: String.t() 51 | @type hash :: String.t() 52 | @type datetime :: String.t() 53 | 54 | @typedoc """ 55 | Represents an Invite object along with the metadata. 56 | 57 | - `code` 58 | 59 | The unique invite code 60 | - `guild` 61 | 62 | The guild this invite is for 63 | - `channel` 64 | 65 | The channel this invite is for 66 | - `inviter` 67 | 68 | The user who created the invite 69 | - `uses` 70 | 71 | The amount of time this invite has been used 72 | - `max_uses` 73 | 74 | The max number of times this invite can be used 75 | - `max_age` 76 | 77 | The duration (seconds) after which the invite will expire 78 | - `temporary` 79 | 80 | Whether this invite grants temporary membership 81 | - `created_at` 82 | 83 | When this invite was created 84 | - `revoked` 85 | 86 | Whether this invite was revoked 87 | """ 88 | @type invite :: %Invite{ 89 | code: String.t(), 90 | guild: invite_guild, 91 | channel: invite_channel, 92 | inviter: User.t(), 93 | uses: Integer, 94 | max_uses: Integer, 95 | max_age: Integer, 96 | temporary: Boolean, 97 | created_at: datetime, 98 | revoked: Boolean 99 | } 100 | @typedoc """ 101 | Represents the guild an invite is for. 102 | 103 | - `id` 104 | 105 | The id of the guild 106 | - `name` 107 | 108 | The name of the guild 109 | - `splash` 110 | 111 | The hash of the guild splash (or `nil`) 112 | - `icon` 113 | 114 | The hash of the guild icon (or `nil`) 115 | """ 116 | 117 | @type invite_guild :: %InviteGuild{ 118 | id: snowflake, 119 | name: String.t(), 120 | splash: hash, 121 | icon: hash 122 | } 123 | @typedoc """ 124 | Represents the channel an invite is for 125 | 126 | - `id` 127 | 128 | The id of the channel 129 | - `name` 130 | 131 | The name of the channel 132 | - `type` 133 | 134 | The type of the channel, either "text" or "voice" 135 | """ 136 | @type invite_channel :: %InviteChannel{ 137 | id: snowflake, 138 | name: String.t(), 139 | type: String.t() 140 | } 141 | 142 | @typedoc """ 143 | Represents a normal text channel in a guild 144 | 145 | _ `id` 146 | 147 | The id of the channel 148 | - `guild_id` 149 | 150 | The id of the guild this channel belongs to 151 | - `position` 152 | 153 | The sorting position of this channel 154 | - `permission_overwrites` 155 | 156 | An array of `%OverWrite{}` structs 157 | - `name` 158 | 159 | The name of this channel 160 | - `topic` 161 | 162 | The topic of the channel 163 | - `nsfw` 164 | 165 | Whether or not the channel is considered nsfw 166 | - `last_message_id` 167 | 168 | The id of the last message sent in the channel, if any 169 | - `parent_id` 170 | 171 | The id of the category this channel belongs to, if any 172 | - `last_pin_timestamp` 173 | 174 | The timestamp of the last channel pin, if any 175 | """ 176 | @type text_channel :: %TextChannel{ 177 | id: snowflake, 178 | guild_id: snowflake, 179 | position: Integer, 180 | permission_overwrites: [overwrite], 181 | name: String.t(), 182 | topic: String.t() | nil, 183 | nsfw: Boolean.t(), 184 | parent_id: snowflake | nil, 185 | last_message_id: snowflake | nil, 186 | last_pin_timestamp: String.t() | nil 187 | } 188 | 189 | @typedoc """ 190 | Represents a channel category in a guild. 191 | 192 | - `id` 193 | 194 | The id of this category 195 | - `guild_id` 196 | 197 | The of the guild this category belongs to 198 | - `position` 199 | 200 | The sorting position of this category 201 | - `permission_overwrites` 202 | 203 | An array of permission overwrites 204 | - `name` 205 | 206 | The name of this category 207 | - `nsfw` 208 | 209 | Whether or not this category is considered nsfw 210 | """ 211 | @type channel_category :: %ChannelCategory{ 212 | id: snowflake, 213 | guild_id: snowflake, 214 | position: Integer, 215 | permission_overwrites: [overwrite], 216 | name: String.t(), 217 | nsfw: Boolean.t() 218 | } 219 | 220 | @typedoc """ 221 | Represents a voice channel in a guild. 222 | 223 | - `id` 224 | 225 | The id of this channel 226 | - `guild_id` 227 | 228 | The id of the guild this channel belongs to 229 | - `position` 230 | 231 | The sorting position of this channel in the guild 232 | - `permission_overwrites` 233 | 234 | An array of permission overwrites for this channel 235 | - `name` 236 | 237 | The name of this channel 238 | - `nsfw` 239 | 240 | Whether or not this channel is considered nsfw 241 | - `bitrate` 242 | 243 | The bitrate for this channel 244 | - `user_limit` 245 | 246 | The max amount of users in this channel, `0` for no limit 247 | - `parent_id` 248 | 249 | The id of the category this channel belongs to, if any 250 | """ 251 | @type voice_channel :: %VoiceChannel{ 252 | id: snowflake, 253 | guild_id: snowflake, 254 | position: Integer, 255 | permission_overwrites: [overwrite], 256 | name: String.t(), 257 | nsfw: Boolean.t(), 258 | bitrate: Integer, 259 | user_limit: Integer, 260 | parent_id: snowflake | nil 261 | } 262 | 263 | @typedoc """ 264 | Represents a private message between the bot and another user. 265 | 266 | - `id` 267 | 268 | The id of this channel 269 | - `recipients` 270 | 271 | A list of users receiving this channel 272 | - `last_message_id` 273 | 274 | The id of the last message sent, if any 275 | """ 276 | @type dm_channel :: %DMChannel{ 277 | id: snowflake, 278 | recipients: [User.t()], 279 | last_message_id: snowflake | nil 280 | } 281 | 282 | @typedoc """ 283 | Represents a dm channel between multiple users. 284 | 285 | - `id` 286 | 287 | The id of this channel 288 | - `owner_id` 289 | 290 | The id of the owner of this channel 291 | - `icon` 292 | 293 | The hash of the image icon for this channel, if it has one 294 | - `name` 295 | 296 | The name of this channel 297 | - `recipients` 298 | 299 | A list of recipients of this channel 300 | - `last_message_id` 301 | The id of the last message sent in this channel, if any 302 | """ 303 | @type group_dm_channel :: %GroupDMChannel{ 304 | id: snowflake, 305 | owner_id: snowflake, 306 | icon: String.t() | nil, 307 | name: String.t(), 308 | recipients: [User.t()], 309 | last_message_id: snowflake | nil 310 | } 311 | 312 | @typedoc """ 313 | The general channel type, representing one of 5 variants. 314 | 315 | The best way of dealing with this type is pattern matching against one of the 5 structs. 316 | """ 317 | @type t :: 318 | text_channel 319 | | voice_channel 320 | | channel_category 321 | | dm_channel 322 | | group_dm_channel 323 | 324 | @doc false 325 | def from_map(map) do 326 | case map["type"] do 327 | 0 -> 328 | TextChannel.from_map(map) 329 | 330 | 1 -> 331 | DMChannel.from_map(map) 332 | 333 | 2 -> 334 | VoiceChannel.from_map(map) 335 | 336 | 3 -> 337 | GroupDMChannel.from_map(map) 338 | 339 | 4 -> 340 | ChannelCategory.from_map(map) 341 | 342 | 5 -> 343 | NewsChannel.from_map(map) 344 | 345 | 6 -> 346 | StoreChannel.from_map(map) 347 | 348 | 13 -> 349 | StageVoiceChannel.from_map(map) 350 | 351 | _ -> 352 | Logger.error("Unkown channel type: #{inspect(map)}") 353 | nil 354 | end 355 | end 356 | end 357 | -------------------------------------------------------------------------------- /lib/Structs/Voice/voice.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Voice do 2 | @moduledoc """ 3 | Contains the types and functions related to voice communication with discord. 4 | 5 | To use the functions in this module, make sure to configure the paths 6 | to `ffmpeg`, as well as `youtube-dl`, like so: 7 | ```elixir 8 | config :alchemy, 9 | ffmpeg_path: "path/to/ffmpeg" 10 | youtube_dl_path: "path/to/youtube-dl" 11 | ``` 12 | If these are not configured, the necessary supervisors for maintaining 13 | voice connections won't be started, and you'll run into errors when trying 14 | to use the functions in this module. 15 | """ 16 | alias Alchemy.{VoiceState, VoiceRegion} 17 | alias Alchemy.Voice.Supervisor, as: VoiceSuper 18 | alias Alchemy.Discord.Gateway.RateLimiter 19 | 20 | @type snowflake :: String.t() 21 | 22 | @typedoc """ 23 | Represents a voice region. 24 | 25 | - `id` 26 | Represent the unique ID for this region. 27 | - `name` 28 | The name of this region. 29 | - `sample_hostname` 30 | An example hostname for the region. 31 | - `sample_port` 32 | An example port for the region. 33 | - `vip` 34 | True if this is a vip-only server. 35 | - `optimal` 36 | True for a single server that is closest to the client. 37 | - `deprecated` 38 | Whether this is a deprecated voice region. 39 | - `custom` 40 | Whether this is a custom voice region. 41 | """ 42 | @type region :: %VoiceRegion{ 43 | id: snowflake, 44 | name: String.t(), 45 | sample_hostname: String.t(), 46 | sample_port: Integer, 47 | vip: Boolean, 48 | optimal: Boolean, 49 | deprecated: Boolean, 50 | custom: Boolean 51 | } 52 | @typedoc """ 53 | Represents the state of a user's voice connection. 54 | 55 | - `guild_id` 56 | The guild id this state is for. 57 | - `channel_id` 58 | The channel id this user is connected to. 59 | - `user_id` 60 | The id of the user this state belongs to. 61 | - `session_id` 62 | The session id for this voice state. 63 | - `deaf` 64 | Whether this user is deafened by the server. 65 | - `mute` 66 | Whether this user is muted by the server. 67 | - `self_deaf` 68 | Whether this user is locally deafened. 69 | - `self_mute` 70 | Whether this user is locally muted. 71 | - `suppress` 72 | Whether this user is muted by the current user. 73 | """ 74 | @type state :: %VoiceState{ 75 | guild_id: snowflake | nil, 76 | channel_id: snowflake, 77 | user_id: snowflake, 78 | session_id: String.t(), 79 | deaf: Boolean, 80 | mute: Boolean, 81 | self_deaf: Boolean, 82 | self_mute: Boolean, 83 | suppress: Boolean 84 | } 85 | @typedoc """ 86 | Represents the audio options that can be passed to different play methods. 87 | 88 | ## Options 89 | - `vol` audio volume, in `%`. Can go above 100 to multiply, e.g. `150`. 90 | """ 91 | @type audio_options :: [{:vol, integer}] 92 | 93 | @doc """ 94 | Joins a voice channel in a guild. 95 | 96 | Only one voice connection per guild is possible with the api. 97 | If you're already connected to the guild, this will not restart the 98 | voice connections, but instead just move you to the channel. 99 | 100 | This function also checks if you're already connected to this channel, 101 | and does nothing if that is the case. 102 | 103 | The timeout will be spread across 2 different message receptions, 104 | i.e. a timeout of `6000` will only wait 3s at every reception. 105 | """ 106 | @spec join(snowflake, snowflake, integer) :: :ok | {:error, String.t()} 107 | def join(guild, channel, timeout \\ 6000) do 108 | case Registry.lookup(Registry.Voice, {guild, :gateway}) do 109 | [{_, ^channel} | _] -> :ok 110 | _ -> VoiceSuper.start_client(guild, channel, timeout) 111 | end 112 | end 113 | 114 | @doc """ 115 | Disconnects from voice in a guild. 116 | 117 | Returns an error if the connection hadn’t been established. 118 | """ 119 | @spec leave(snowflake) :: :ok | {:error, String.t()} 120 | def leave(guild) do 121 | case Registry.lookup(Registry.Voice, {guild, :gateway}) do 122 | [] -> 123 | {:error, "You're not joined to voice in this guild"} 124 | 125 | [{pid, _} | _] -> 126 | Supervisor.terminate_child(VoiceSuper.Gateway, pid) 127 | RateLimiter.change_voice_state(guild, nil) 128 | end 129 | end 130 | 131 | @doc """ 132 | Starts playing a music file on a guild's voice connection. 133 | 134 | Returns an error if the client isn't connected to the guild, 135 | or if the file does not exist. 136 | 137 | ## Examples 138 | ```elixir 139 | Voice.join("666", "666") 140 | Voice.play_file("666", "cool_song.mp3") 141 | ``` 142 | """ 143 | @spec play_file(snowflake, Path.t(), audio_options) :: :ok | {:error, String.t()} 144 | def play_file(guild, file_path, options \\ []) do 145 | with [{pid, _} | _] <- Registry.lookup(Registry.Voice, {guild, :controller}), 146 | true <- File.exists?(file_path) do 147 | GenServer.call(pid, {:play, file_path, :file, options}) 148 | else 149 | [] -> {:error, "You're not joined to voice in this guild"} 150 | false -> {:error, "This file does not exist"} 151 | end 152 | end 153 | 154 | defp play_type(guild, type, data, options) do 155 | case Registry.lookup(Registry.Voice, {guild, :controller}) do 156 | [] -> {:error, "You're not joined to voice in this guild"} 157 | [{pid, _} | _] -> GenServer.call(pid, {:play, data, type, options}) 158 | end 159 | end 160 | 161 | @doc """ 162 | Starts playing audio from a url. 163 | 164 | For this to work, the url must be one of the 165 | [supported sites](https://rg3.github.io/youtube-dl/supportedsites.html). 166 | This function does not check the validity of this url, so if it's invalid, 167 | an error will get logged, and no audio will be played. 168 | """ 169 | @spec play_url(snowflake, String.t(), audio_options) :: :ok | {:error, String.t()} 170 | def play_url(guild, url, options \\ []) do 171 | play_type(guild, :url, url, options) 172 | end 173 | 174 | @doc """ 175 | Starts playing audio from an `iodata`, or a stream of `iodata`. 176 | 177 | Similar to `play_url/2` except it doesn't create a stream from 178 | `youtube-dl` for you. 179 | """ 180 | @spec play_iodata(snowflake, iodata | Enumerable.t(), audio_options) :: 181 | :ok | {:error, String.t()} 182 | def play_iodata(guild, data, options \\ []) do 183 | play_type(guild, :iodata, data, options) 184 | end 185 | 186 | @doc """ 187 | Stops playing audio on a guild's voice connection. 188 | 189 | Returns an error if the connection hadn't been established. 190 | """ 191 | @spec stop_audio(snowflake) :: :ok | {:error, String.t()} 192 | def stop_audio(guild) do 193 | case Registry.lookup(Registry.Voice, {guild, :controller}) do 194 | [] -> {:error, "You're not joined to voice in this guild"} 195 | [{pid, _} | _] -> GenServer.call(pid, :stop_playing) 196 | end 197 | end 198 | 199 | @doc """ 200 | Lets this process listen for the end of an audio track in a guild. 201 | 202 | This will subscribe this process up until the next time an audio track 203 | ends, to react to this, you'll want to handle the message in some way, e.g. 204 | ```elixir 205 | Voice.listen_for_end(guild) 206 | receive do 207 | {:audio_stopped, ^guild} -> IO.puts "audio has stopped" 208 | end 209 | ``` 210 | This is mainly designed for use in genservers, or other places where you don't 211 | want to block. If you do want to block and wait immediately, try 212 | `wait_for_end/2` instead. 213 | 214 | ## Examples 215 | Use in a genserver: 216 | ```elixir 217 | def handle_info({:audio_stopped, guild}, state) do 218 | IO.puts "audio has stopped in \#{guild}" 219 | Voice.listen_for_end(guild) 220 | {:noreply, state} 221 | end 222 | ``` 223 | """ 224 | @spec listen_for_end(snowflake) :: :ok | {:error, String.t()} 225 | def listen_for_end(guild) do 226 | case Registry.lookup(Registry.Voice, {guild, :controller}) do 227 | [] -> {:error, "You're not joined to voice in this guild"} 228 | [{pid, _} | _] -> GenServer.call(pid, :add_listener) 229 | end 230 | end 231 | 232 | @doc """ 233 | Blocks the current process until audio has stopped playing in a guild. 234 | 235 | This is a combination of `listen_for_end/1` and a receive block, 236 | however this will return an error if the provided timeout is exceeded. 237 | This is useful for implementing automatic track listing, e.g. 238 | ```elixir 239 | def playlist(guild, tracks) do 240 | Enum.map(tracks, fn track -> 241 | Voice.play_file(guild, track) 242 | Voice.wait_for_end(guild) 243 | end) 244 | end 245 | ``` 246 | """ 247 | @spec wait_for_end(snowflake, integer | :infinity) :: :ok | {:error, String.t()} 248 | def wait_for_end(guild, timeout \\ :infinity) do 249 | listen_for_end(guild) 250 | 251 | receive do 252 | {:audio_stopped, ^guild} -> :ok 253 | after 254 | timeout -> {:error, "Timed out waiting for audio"} 255 | end 256 | end 257 | 258 | @doc """ 259 | Returns which channel the client is connected to in a guild. 260 | 261 | Returns `nil` if there is no connection. 262 | """ 263 | @spec which_channel(snowflake) :: snowflake | nil 264 | def which_channel(guild) do 265 | case Registry.lookup(Registry.Voice, {guild, :gateway}) do 266 | [{_, channel} | _] -> channel 267 | _ -> nil 268 | end 269 | end 270 | end 271 | -------------------------------------------------------------------------------- /lib/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Cache do 2 | @moduledoc """ 3 | This module provides a handful of useful functions to interact with the cache. 4 | 5 | By default, Alchemy caches a great deal of information given to it, notably about 6 | guilds. In general, using the cache should be prioritised over using the api 7 | functions in `Alchemy.Client`. However, a lot of struct modules have "smart" 8 | functions that will correctly balance the cache and the api, as well as use macros 9 | to get information from the context of commands. 10 | """ 11 | alias Alchemy.Cache.{Guilds, Guilds.GuildSupervisor, Channels} 12 | alias Alchemy.{Channel.DMChannel, Channel, Guild, User, VoiceState, Voice} 13 | alias Alchemy.Guild.{Emoji, GuildMember, Presence, Role} 14 | alias Alchemy.Discord.Gateway.RateLimiter, as: Gateway 15 | import Alchemy.Structs, only: [to_struct: 2] 16 | 17 | @type snowflake :: String.t() 18 | @doc """ 19 | Gets the corresponding guild_id for a channel. 20 | 21 | In case the channel guild can't be found, `:none` will be returned. 22 | 23 | This is useful when the guild_id is needed for some kind of task, but there's no 24 | need for getting the whole struct. Because of how the registry is set up, getting 25 | the entire guild requires a whole extra step, that passes through this one anyways. 26 | """ 27 | @spec guild_id(snowflake) :: {:ok, snowflake} | {:error, String.t()} 28 | def guild_id(channel_id) do 29 | Channels.lookup(channel_id) 30 | end 31 | 32 | @doc """ 33 | Fetches a guild from the cache by a given id. 34 | 35 | By default, this method needs the guild_id, but keywords can be used to specify 36 | a different id, and use the appropiate paths to get the guild using that. 37 | 38 | In general there are "smarter" methods, that will deal with getting the id for you; 39 | nonetheless, the need for this function sometimes exists. 40 | 41 | ## Keywords 42 | - `channel` 43 | Using this keyword will fetch the information for the guild a channel belongs to. 44 | """ 45 | @spec guild(snowflake) :: {:ok, Guild.t()} | {:error, String.t()} 46 | def guild(channel: channel_id) do 47 | with {:ok, id} <- guild_id(channel_id) do 48 | guild(id) 49 | end 50 | end 51 | 52 | def guild(guild_id) do 53 | case Guilds.safe_call(guild_id, :show) do 54 | {:error, :no_guild} -> 55 | {:error, "You don't seem to be in this guild"} 56 | 57 | {:ok, %{"unavailable" => true}} -> 58 | {:error, "This guild hasn't been loaded in the cache yet"} 59 | 60 | {:ok, guild} -> 61 | {:ok, guild |> Guilds.de_index() |> Guild.from_map()} 62 | end 63 | end 64 | 65 | defp access(guild_id, section, id, module) when is_atom(module) do 66 | access(guild_id, section, id, &module.from_map/1) 67 | end 68 | 69 | defp access(guild_id, section, id, function) do 70 | maybe_val = 71 | with {:ok, guild} <- Guilds.safe_call(guild_id, {:section, section}) do 72 | {:ok, guild[id]} 73 | end 74 | 75 | case maybe_val do 76 | {:error, :no_guild} -> 77 | {:error, "You don't seem to be in this guild"} 78 | 79 | {:ok, nil} -> 80 | {:error, "Failed to find an entry for #{id} in section #{section}"} 81 | 82 | {:ok, some} -> 83 | {:ok, function.(some)} 84 | end 85 | end 86 | 87 | @doc """ 88 | Gets a member from a cache, by guild and member id. 89 | """ 90 | @spec member(snowflake, snowflake) :: {:ok, Guild.member()} | {:error, String.t()} 91 | def member(guild_id, member_id) do 92 | access(guild_id, "members", member_id, GuildMember) 93 | end 94 | 95 | @doc """ 96 | Gets a specific role in a guild. 97 | """ 98 | @spec role(snowflake, snowflake) :: {:ok, Guild.role()} | {:error, String.t()} 99 | def role(guild_id, role_id) do 100 | access(guild_id, "roles", role_id, &to_struct(&1, Role)) 101 | end 102 | 103 | @doc """ 104 | Gets the presence of a user in a certain guild. 105 | 106 | This contains info such as their status, and roles. 107 | """ 108 | @spec presence(snowflake, snowflake) :: {:ok, Presence.t()} | {:error, String.t()} 109 | def presence(guild_id, user_id) do 110 | access(guild_id, "presences", user_id, Presence) 111 | end 112 | 113 | @doc """ 114 | Retrieves a custom emoji by id in a guild. 115 | """ 116 | @spec emoji(snowflake, snowflake) :: {:ok, Guild.emoji()} | {:error, String.t()} 117 | def emoji(guild_id, emoji_id) do 118 | access(guild_id, "emojis", emoji_id, &to_struct(&1, Emoji)) 119 | end 120 | 121 | @doc """ 122 | Retrieves a user's voice state by id in a guild. 123 | """ 124 | @spec voice_state(snowflake, snowflake) :: {:ok, Voice.state()} | {:error, String.t()} 125 | def voice_state(guild_id, user_id) do 126 | access(guild_id, "voice_states", user_id, &to_struct(&1, VoiceState)) 127 | end 128 | 129 | @doc """ 130 | Retrieves a specific channel in a guild. 131 | """ 132 | @spec channel(snowflake, snowflake) :: {:ok, Channel.t()} | {:error, String.t()} 133 | def channel(guild_id, channel_id) do 134 | access(guild_id, "channels", channel_id, Channel) 135 | end 136 | 137 | # Returns the corresponding protocol for an atom key. 138 | # This is mainly needed for `search/2` 139 | defp cache_sections(key) do 140 | %{ 141 | members: {"members", &GuildMember.from_map/1}, 142 | roles: {"roles", &to_struct(&1, Role)}, 143 | presences: {"presences", &Presence.from_map/1}, 144 | voice_states: {"voice_states", &to_struct(&1, VoiceState)}, 145 | emojis: {"emojis", &to_struct(&1, Emoji)}, 146 | channels: {"channels", &Channel.from_map/1} 147 | }[key] 148 | end 149 | 150 | @doc """ 151 | Searches across all guild for information. 152 | 153 | The section is the type of object to search for. The possibilities are: 154 | `:guilds`, `:members`, `:roles`, `:presences`, `:voice_states`, `:emojis`, 155 | `:channels` 156 | 157 | The filter is a function returning a boolean, that allows you to filter out 158 | elements from this list. 159 | 160 | The return type will be a struct of the same type of the section searched for. 161 | ## Examples 162 | ```elixir 163 | Cache.search(:members, fn x -> String.length(x.nick) < 10 end) 164 | ``` 165 | This will return a list of all members whose nickname is less than 10 166 | characters long. 167 | ```elixir 168 | Cache.search(:roles, &match?(%{name: "Cool Kids"}, &1)) 169 | ``` 170 | This is a good example of using the `match?/2` 171 | function to filter against a pattern. 172 | ```elixir 173 | Cache.search(:guilds, &match?(%{name: "Test"}, &1)) 174 | ``` 175 | Will match any guilds named "Test" in the cache. 176 | """ 177 | @spec search(atom, (any -> Boolean)) :: [struct] 178 | def search(:guilds, filter) do 179 | Supervisor.which_children(GuildSupervisor) 180 | |> Stream.map(fn {_, pid, _, _} -> pid end) 181 | |> Task.async_stream(&GenServer.call(&1, :show)) 182 | |> Stream.filter(fn {:ok, val} -> 183 | val["unavailable"] != true 184 | end) 185 | |> Stream.map(fn {:ok, val} -> 186 | val |> Guilds.de_index() |> Guild.from_map() 187 | end) 188 | |> Enum.filter(filter) 189 | end 190 | 191 | def search(:private_channels, filter) do 192 | fold = fn {_id, val}, acc -> 193 | if filter.(val) do 194 | [val | acc] 195 | else 196 | acc 197 | end 198 | end 199 | 200 | :ets.foldr(fold, [], :priv_channels) 201 | end 202 | 203 | def search(section, filter) do 204 | {key, de_indexer} = cache_sections(section) 205 | 206 | Supervisor.which_children(GuildSupervisor) 207 | |> Stream.map(fn {_, pid, _, _} -> pid end) 208 | |> Task.async_stream(&GenServer.call(&1, {:section, key})) 209 | |> Stream.flat_map(fn {:ok, v} -> Map.values(v) end) 210 | |> Stream.map(de_indexer) 211 | |> Enum.filter(filter) 212 | end 213 | 214 | @doc """ 215 | Fetches a private_channel in the cache by id of the channel. 216 | 217 | Takes a DMChannel id. Alternatively, `user: user_id` can be passed to find 218 | the private channel related to a user. 219 | """ 220 | @spec private_channel(snowflake) :: {:ok, Channel.dm_channel()} | {:error, String.t()} 221 | def private_channel(user: user_id) do 222 | case :ets.lookup(:priv_channels, user_id) do 223 | [{_, id}] -> private_channel(id) 224 | [] -> {:error, "Failed to find a DM channel for this user: #{user_id}"} 225 | end 226 | end 227 | 228 | def private_channel(channel_id) do 229 | case :ets.lookup(:priv_channels, channel_id) do 230 | [{_, channel}] -> {:ok, DMChannel.from_map(channel)} 231 | [] -> {:error, "Failed to find a DM channel entry for #{channel_id}."} 232 | end 233 | end 234 | 235 | @doc """ 236 | Gets the user struct for this client from the cache. 237 | 238 | ## Examples 239 | ```elixir 240 | Cogs.def hello do 241 | Cogs.say "hello, my name is \#{Cache.user().name}" 242 | end 243 | ``` 244 | """ 245 | @spec user :: User.t() 246 | def user do 247 | GenServer.call(Alchemy.Cache.User, :get) 248 | |> to_struct(User) 249 | end 250 | 251 | @doc """ 252 | Requests the loading of offline guild members for a guild. 253 | 254 | Guilds should automatically get 250 offline members after the 255 | `:ready` event, however, you can use this method to request a fuller 256 | list if needed. 257 | 258 | The `username` is used to only select members whose username starts 259 | with a certain string; `""` won't do any filtering. The `limit` 260 | specifies the amount of members to get; `0` for unlimited. 261 | 262 | There's a ratelimit of ~100 requests per shard per minute on this 263 | function, so be wary of the fact that this might block a process. 264 | """ 265 | def load_guild_members(guild_id, username \\ "", limit \\ 0) do 266 | Gateway.request_guild_members(guild_id, username, limit) 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /lib/Structs/Guild/guild.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Guild do 2 | alias Alchemy.{Channel, User, Voice, VoiceState} 3 | alias Alchemy.Guild.{Emoji, GuildMember, Integration, Presence, Role} 4 | import Alchemy.Structs 5 | 6 | @moduledoc """ 7 | Guilds represent a collection of users in a "server". This module contains 8 | information about the types, and subtypes related to guilds, as well 9 | as some useful functions related to them. 10 | """ 11 | @type snowflake :: String.t() 12 | @typedoc """ 13 | An iso_8601 timestamp. 14 | """ 15 | @type timestamp :: String.t() 16 | @typedoc """ 17 | Represents a guild. 18 | 19 | - `id` 20 | 21 | The id of this guild. 22 | - `name` 23 | 24 | The name of this guild. 25 | - `icon` 26 | The image hash of the icon image. 27 | - `splash` 28 | The image hash of the splash image. Not a lot of guilds have a hash. 29 | - `owner_id` 30 | The user id of the guild's owner. 31 | - `region` 32 | The region of the guild. 33 | - `afk_channel_id` 34 | The id of the afk channel, if the guild has one. 35 | - `afk_timeout` 36 | The afk timeout in seconds. 37 | - `embed_enabled` 38 | Whether this guild is embeddable. 39 | - `verification_level` 40 | The level of verification this guild requires. 41 | - `default_message_notifications` 42 | The default message notifications level. 43 | - `roles` 44 | A list of the roles in this server. 45 | - `emojis` 46 | A list of custom emojis in this server. 47 | - `features` 48 | A list of guild features. 49 | - `mfa_level` 50 | The required mfa level for the guild. 51 | 52 | The following fields will be missing for guilds accessed from outside the Cache: 53 | - `joined_at` 54 | The timestamp of guild creation. 55 | - `large` 56 | Whether or not this guild is considered "large". 57 | - `unavailable` 58 | This should never be true for guilds. 59 | - `member_count` 60 | The number of members a guild contains. 61 | - `voice_states` 62 | A list of voice states of the guild. 63 | - `members` 64 | A list of members in the guild. 65 | - `channels` 66 | A list of channels in the guild. 67 | - `presences` 68 | A list of presences in the guild. 69 | 70 | """ 71 | @type t :: %__MODULE__{ 72 | id: snowflake, 73 | name: String.t(), 74 | icon: String.t(), 75 | splash: String.t() | nil, 76 | owner_id: snowflake, 77 | region: String.t(), 78 | afk_channel_id: String.t() | nil, 79 | afk_timeout: Integer, 80 | embed_enabled: Boolean, 81 | verification_level: Integer, 82 | default_message_notifications: Integer, 83 | roles: [Guild.role()], 84 | emojis: [emoji], 85 | features: [String.t()], 86 | mfa_level: Integer, 87 | joined_at: timestamp, 88 | large: Boolean, 89 | unavailable: Boolean, 90 | member_count: Integer, 91 | voice_states: [Voice.state()], 92 | members: [member], 93 | channels: [Channel.t()], 94 | presences: [Presence.t()] 95 | } 96 | 97 | defstruct [ 98 | :id, 99 | :name, 100 | :icon, 101 | :splash, 102 | :owner_id, 103 | :region, 104 | :afk_channel_id, 105 | :afk_timeout, 106 | :embed_enabled, 107 | :verification_level, 108 | :default_message_notifications, 109 | :roles, 110 | :emojis, 111 | :features, 112 | :mfa_level, 113 | :joined_at, 114 | :large, 115 | :unavailable, 116 | :member_count, 117 | :voice_states, 118 | :members, 119 | :channels, 120 | :presences 121 | ] 122 | 123 | @typedoc """ 124 | Represents a member in a guild. 125 | 126 | - `user` 127 | A user struct containing information about the underlying user. 128 | - `nick` 129 | An optional nickname for this member. 130 | - `roles` 131 | A list of ids corresponding to roles the member has. 132 | - `joined_at` 133 | The timestamp of when this member joined the guild. 134 | - `deaf` 135 | Whether the user is currently deafened. 136 | - `mute` 137 | Whether the user is currently muted. 138 | """ 139 | @type member :: %GuildMember{ 140 | user: User.t(), 141 | nick: String.t() | nil, 142 | roles: [snowflake], 143 | joined_at: timestamp, 144 | deaf: Boolean, 145 | mute: Boolean 146 | } 147 | @typedoc """ 148 | Represents a custom emoji in a guild. 149 | 150 | The string representation of this struct will be the markdown 151 | necessary to use it. i.e. `Cogs.say("\#{emoji}")` will send the emoji. 152 | 153 | - `id` 154 | The id of this emoji. 155 | - `name` 156 | The name of this emoji. 157 | - `roles` 158 | A list of role ids who can use this role. 159 | - `require_colons` 160 | Whether or not this emoji must be wrapped in colons. 161 | - `managed` 162 | Whether or not this emoji is managed. 163 | """ 164 | @type emoji :: %Emoji{ 165 | id: String.t(), 166 | name: String.t(), 167 | roles: [String.t()], 168 | require_colons: Boolean, 169 | managed: Boolean 170 | } 171 | @typedoc """ 172 | Represents the account of an integration. 173 | 174 | - `id` 175 | The id of the account. 176 | - `name` 177 | The name of the account. 178 | """ 179 | @type integration_account :: %Integration.Account{ 180 | id: snowflake, 181 | name: String.t() 182 | } 183 | @typedoc """ 184 | Represents an guild's integration with a service, (i.e. twitch) 185 | 186 | - `id` 187 | The id of the integration. 188 | - `name` 189 | The name of the integration. 190 | - `type` 191 | Integration type; youtube, twitch, etc. 192 | - `enabled` 193 | Whether or not the integration is enabled. 194 | - `syncing` 195 | Whether or not the integration is syncing. 196 | - `role_id` 197 | The id of the role associated with "subscribers" to this integration. 198 | - `expire_behaviour` 199 | The behaviour of expiring subscribers. 200 | - `expire_grace_period` 201 | The grace period before expiring subscribers. 202 | - `user` 203 | The user for this integration. 204 | - `account` 205 | The integration's account information. 206 | - `synced_at` 207 | When this integration was last synced. 208 | """ 209 | @type integration :: %Integration{ 210 | id: snowflake, 211 | name: String.t(), 212 | type: String.t(), 213 | enabled: Boolean, 214 | syncing: Boolean, 215 | role_id: snowflake, 216 | expire_behaviour: Integer, 217 | expire_grace_period: Integer, 218 | user: User.t(), 219 | account: integration_account, 220 | synced_at: timestamp 221 | } 222 | 223 | @typedoc """ 224 | Represents a role in a guild. 225 | 226 | - `id` 227 | The id of the role. 228 | - `name` 229 | The name of the role. 230 | - `color` 231 | The color of the role. 232 | - `hoist` 233 | Whether the role is "hoisted" above others in the sidebar. 234 | - `position` 235 | The position of the role in a guild. 236 | - `permissions` 237 | The bitset of permissions for this role. See the `Permissions` module 238 | for more information. 239 | - `managed` 240 | Whether this role is managed by an integration. 241 | - `mentionable` 242 | Whether this role is mentionable. 243 | """ 244 | @type role :: %Role{ 245 | id: snowflake, 246 | name: String.t(), 247 | color: Integer, 248 | hoist: Boolean, 249 | position: Integer, 250 | permissions: Integer, 251 | managed: Boolean, 252 | mentionable: Boolean 253 | } 254 | @typedoc """ 255 | Represents the presence of a user in a guild. 256 | 257 | - `user` 258 | The user this presence is for. 259 | - `roles` 260 | A list of role ids this user belongs to. 261 | - `game` 262 | The current activity of the user, or `nil`. 263 | - `guild_id` 264 | The id of the guild this presences is in. 265 | - `status` 266 | "idle", "online", or "offline" 267 | """ 268 | @type presence :: %Presence{ 269 | user: User.t(), 270 | roles: [snowflake], 271 | game: String.t() | nil, 272 | guild_id: snowflake, 273 | status: String.t() 274 | } 275 | 276 | @doc """ 277 | Finds the highest ranked role of a member in a guild. 278 | 279 | This is useful, because the permissions and color 280 | of the highest role are the ones that apply to that member. 281 | """ 282 | @spec highest_role(t, member) :: role 283 | def highest_role(guild, member) do 284 | guild.roles 285 | |> Enum.sort_by(& &1.position) 286 | # never null because of the @everyone role 287 | |> Enum.find(&(&1 in member.roles)) 288 | end 289 | 290 | defmacrop is_valid_guild_icon_url(type, size) do 291 | quote do 292 | unquote(type) in ["jpg", "jpeg", "png", "webp"] and 293 | unquote(size) in [128, 256, 512, 1024, 2048] 294 | end 295 | end 296 | 297 | @doc """ 298 | Get the icon image URL for the given guild. 299 | If the guild does not have any icon, returns `nil`. 300 | 301 | ## Parameters 302 | - `type`: The returned image format. Can be any of `jpg`, `jpeg`, `png`, or `webp`. 303 | - `size`: The desired size of the returned image. Must be a power of two. 304 | If the parameters do not match these conditions, an `ArgumentError` is raised. 305 | """ 306 | @spec icon_url(__MODULE__.t(), String.t(), 16..2048) :: String.t() 307 | def icon_url(guild, type \\ "png", size \\ 256) when is_valid_guild_icon_url(type, size) do 308 | case guild.icon do 309 | nil -> nil 310 | hash -> "https://cdn.discordapp.com/icons/#{guild.id}/#{hash}.#{type}?size=#{size}" 311 | end 312 | end 313 | 314 | def icon_url(_guild, _type, _size) do 315 | raise ArgumentError, message: "invalid icon URL type and / or size" 316 | end 317 | 318 | @doc false 319 | def from_map(map) do 320 | map 321 | |> field_map("roles", &map_struct(&1, Role)) 322 | |> field_map("emojis", &map_struct(&1, Emoji)) 323 | |> field_map?("voice_states", &map_struct(&1, VoiceState)) 324 | |> fields_from_map?("members", GuildMember) 325 | |> fields_from_map?("channels", Channel) 326 | |> fields_from_map?("presences", Presence) 327 | |> to_struct(__MODULE__) 328 | end 329 | end 330 | -------------------------------------------------------------------------------- /lib/Structs/Messages/Embed/embed.ex: -------------------------------------------------------------------------------- 1 | defmodule Alchemy.Embed do 2 | @moduledoc """ 3 | A module containing structs and functions relative to Embeds. 4 | 5 | Embeds allow you to format messages in a structured, and quite pretty way; much more 6 | than can be done with simple text. 7 | For a basic idea of how embeds work, check this 8 | [link](https://cdn.discordapp.com/attachments/84319995256905728/252292324967710721/embed.png). 9 | 10 | ## Example Usage 11 | ```elixir 12 | Cogs.def embed do 13 | %Embed{} 14 | |> title("The BEST embed") 15 | |> description("the best description") 16 | |> image("http://i.imgur.com/4AiXzf8.jpg") 17 | |> Embed.send 18 | end 19 | ``` 20 | Note that this is equivalent to: 21 | ```elixir 22 | Cogs.def embed do 23 | %Embed{title: "The BEST embed", 24 | description: "the best description", 25 | image: "http://i.imgur.com/4AiXzf8.jpg"} 26 | |> Embed.send 27 | end 28 | ``` 29 | ## File Attachments 30 | The fields that take urls can also take a special "attachment" 31 | url referencing files uploaded alongside the embed. 32 | ```elixir 33 | Cogs.def foo do 34 | %Embed{} 35 | |> image("attachment://foo.png") 36 | |> Embed.send("", file: "foo.png") 37 | end 38 | ``` 39 | """ 40 | import Alchemy.Structs 41 | alias Alchemy.Attachment 42 | alias Alchemy.Embed.{Footer, Image, Video, Provider, Author, Field, Thumbnail} 43 | alias Alchemy.Embed 44 | 45 | @type url :: String.t() 46 | @type t :: %__MODULE__{ 47 | title: String.t(), 48 | type: String.t(), 49 | description: String.t(), 50 | url: String.t(), 51 | timestamp: String.t(), 52 | color: Integer, 53 | footer: footer, 54 | image: image, 55 | thumbnail: thumbnail, 56 | video: video, 57 | provider: provider, 58 | author: author, 59 | fields: [field] 60 | } 61 | @derive Poison.Encoder 62 | defstruct [ 63 | :title, 64 | :type, 65 | :description, 66 | :url, 67 | :timestamp, 68 | :color, 69 | :footer, 70 | :image, 71 | :thumbnail, 72 | :video, 73 | :provider, 74 | :author, 75 | fields: [] 76 | ] 77 | 78 | @typedoc """ 79 | Represents the author of an embed. 80 | 81 | - `name` 82 | 83 | The name of the author 84 | - `url` 85 | 86 | The author's url 87 | - `icon_url` 88 | 89 | A link to the author's icon image 90 | - `proxy_icon_url` 91 | 92 | A proxied url for the author's icon image 93 | """ 94 | @type author :: %Author{ 95 | name: String.t(), 96 | url: url, 97 | icon_url: url, 98 | proxy_icon_url: url 99 | } 100 | @typedoc """ 101 | Represents a file attached to an embed. 102 | 103 | - `id` 104 | 105 | The attachment id 106 | - `filename` 107 | 108 | The name of the file attached 109 | - `size` 110 | 111 | The size of the file attached 112 | - `url` 113 | 114 | The source url of a file 115 | - `proxy_url` 116 | 117 | A proxied url of a file 118 | - `height` 119 | 120 | The height of the file, if it's an image 121 | - `width` 122 | 123 | The width of a file, if it's an image 124 | """ 125 | @type attachment :: %Attachment{ 126 | id: String.t(), 127 | filename: String.t(), 128 | size: Integer, 129 | url: url, 130 | proxy_url: url, 131 | height: Integer | nil, 132 | width: Integer | nil 133 | } 134 | @typedoc """ 135 | Represents a field in an embed. 136 | 137 | - `name` 138 | 139 | The title of the field 140 | - `value` 141 | 142 | The text of the field 143 | - `inline` 144 | 145 | Whether or not the field should be aligned with other inline fields. 146 | """ 147 | @type field :: %Field{ 148 | name: String.t(), 149 | value: String.t(), 150 | inline: Boolean 151 | } 152 | @typedoc """ 153 | Represents an Embed footer. 154 | 155 | - `text` 156 | 157 | The text of the footer 158 | - `icon_url` 159 | 160 | The url of the image in the footer 161 | - `proxy_icon_url` 162 | 163 | The proxied url of the footer's icon. Setting this when sending an embed serves 164 | no purpose. 165 | """ 166 | @type footer :: %Footer{ 167 | text: String.t(), 168 | icon_url: url, 169 | proxy_icon_url: url 170 | } 171 | @typedoc """ 172 | Represents the image of an embed. 173 | 174 | - `url` 175 | 176 | A link to this image 177 | 178 | The following parameters shouldn't be set when sending embeds: 179 | - `proxy_url` 180 | 181 | A proxied url of the image 182 | - `height` 183 | 184 | The height of the image. 185 | - `width` 186 | 187 | The width of the image. 188 | """ 189 | @type image :: %Image{ 190 | url: url, 191 | proxy_url: url, 192 | height: Integer, 193 | width: Integer 194 | } 195 | @typedoc """ 196 | Represents the provider of an embed. 197 | 198 | This is usually comes from a linked resource (youtube video, etc.) 199 | 200 | - `name` 201 | 202 | The name of the provider 203 | - `url` 204 | 205 | The source of the provider 206 | """ 207 | @type provider :: %Provider{ 208 | name: String.t(), 209 | url: url 210 | } 211 | @typedoc """ 212 | Represents the thumnail of an embed. 213 | 214 | - `url` 215 | 216 | A link to the thumbnail image. 217 | - `proxy_url` 218 | 219 | A proxied link to the thumbnail image 220 | - `height` 221 | 222 | The height of the thumbnail 223 | - `width` 224 | 225 | The width of the thumbnail 226 | """ 227 | @type thumbnail :: %Thumbnail{ 228 | url: url, 229 | proxy_url: url, 230 | height: Integer, 231 | width: Integer 232 | } 233 | @typedoc """ 234 | Represents a video attached to an embed. 235 | 236 | Users can't set this themselves. 237 | - `url` 238 | 239 | The source of the video 240 | - `height` 241 | 242 | The height of the video 243 | - `width` 244 | 245 | The width of the video 246 | """ 247 | @type video :: %Video{ 248 | url: url, 249 | height: Integer, 250 | width: Integer 251 | } 252 | 253 | @doc false 254 | def from_map(map) do 255 | map 256 | |> field?("footer", Footer) 257 | |> field?("image", Image) 258 | |> field?("video", Video) 259 | |> field?("provider", Provider) 260 | |> field?("author", Author) 261 | |> field_map("fields", &map_struct(&1, Field)) 262 | |> to_struct(__MODULE__) 263 | end 264 | 265 | # removes all the null keys from the map 266 | @doc false 267 | # This will also convert datetime objects into iso_8601 268 | def build(struct) when is_map(struct) do 269 | {_, struct} = Map.pop(struct, :__struct__) 270 | 271 | struct 272 | |> Enum.filter(fn {_, v} -> v != nil and v != [] end) 273 | |> Enum.map(fn {k, v} -> {k, build(v)} end) 274 | |> Enum.into(%{}) 275 | end 276 | 277 | def build(value) do 278 | value 279 | end 280 | 281 | @doc """ 282 | Adds a title to an embed. 283 | ## Examples 284 | ```elixir 285 | Cogs.def title(string) do 286 | %Embed{} 287 | |> title(string) 288 | |> Embed.send 289 | end 290 | """ 291 | @spec title(Embed.t(), String.t()) :: Embed.t() 292 | def title(embed, string) do 293 | %{embed | title: string} 294 | end 295 | 296 | @doc """ 297 | Sets the url for an embed. 298 | 299 | ## Examples 300 | ```elixir 301 | Cogs.def embed(url) do 302 | %Embed{} 303 | |> url(url) 304 | |> Embed.send 305 | end 306 | ``` 307 | """ 308 | @spec url(Embed.t(), url) :: Embed.t() 309 | def url(embed, url) do 310 | %{embed | url: url} 311 | end 312 | 313 | @doc """ 314 | Adds a description to an embed. 315 | 316 | ```elixir 317 | Cogs.def embed(description) do 318 | %Embed{} 319 | |> title("generic title") 320 | |> description(description) 321 | |> Embed.send 322 | end 323 | ``` 324 | """ 325 | @spec description(Embed.t(), String.t()) :: Embed.t() 326 | def description(embed, string) do 327 | %{embed | description: string} 328 | end 329 | 330 | @doc """ 331 | Adds author information to an embed. 332 | 333 | Note that the `proxy_icon_url`, `height`, and `width` fields have no effect, 334 | when using a pre-made `Author` struct. 335 | ## Options 336 | - `name` 337 | 338 | The name of the author. 339 | - `url` 340 | 341 | The url of the author. 342 | - `icon_url` 343 | 344 | The url of the icon to display. 345 | ## Examples 346 | ```elixir 347 | Cogs.def embed do 348 | %Embed{} 349 | |> author(name: "John", 350 | url: "https://discord.com/developers" 351 | icon_url: "http://i.imgur.com/3nuwWCB.jpg") 352 | |> Embed.send 353 | end 354 | ``` 355 | """ 356 | @spec author(Embed.t(), [name: String.t(), url: url, icon_url: url] | Author.t()) :: 357 | Embed.t() 358 | def author(embed, %Author{} = author) do 359 | %{embed | author: author} 360 | end 361 | 362 | def author(embed, options) do 363 | %{embed | author: Enum.into(options, %{})} 364 | end 365 | 366 | @doc """ 367 | Sets the color of an embed 368 | 369 | Color should be 3 byte integer, with each byte representing a single 370 | color component; i.e. `0xRrGgBb` 371 | ## Examples 372 | ```elixir 373 | Cogs.def embed do 374 | {:ok, message} = 375 | %Embed{description: "the best embed"} 376 | |> color(0xc13261) 377 | |> Embed.send 378 | Process.sleep(2000) 379 | Client.edit_embed(message, embed |> color(0x5aa4d4)) 380 | end 381 | ``` 382 | """ 383 | @spec color(Embed.t(), Integer) :: Embed.t() 384 | def color(embed, integer) do 385 | %{embed | color: integer} 386 | end 387 | 388 | @doc """ 389 | Adds a footer to an embed. 390 | 391 | Note that the `proxy_icon_url` field has no effect, 392 | when using a pre-made `Footer` struct. 393 | ## Options 394 | - `text` 395 | 396 | The content of the footer. 397 | - `icon_url` 398 | 399 | The icon the footer should have 400 | ## Examples 401 | ```elixir 402 | Cogs.def you do 403 | %Embed{} 404 | |> footer(text: "<- this is you", 405 | icon_url: message.author |> User.avatar_url) 406 | |> Embed.send 407 | end 408 | ``` 409 | """ 410 | @spec footer(Embed.t(), [text: String.t(), icon_url: url] | Footer.t()) :: Embed.t() 411 | def footer(embed, %Footer{} = footer) do 412 | %{embed | footer: footer} 413 | end 414 | 415 | def footer(embed, options) do 416 | %{embed | footer: Enum.into(options, %{})} 417 | end 418 | 419 | @doc """ 420 | Adds a field to an embed. 421 | 422 | Fields are appended when using this method, so the order you pipe them in, 423 | is the order they'll end up when sent. The name and value must be non empty 424 | strings. You can have a maximum of `25` fields. 425 | ## Parameters 426 | - `name` 427 | 428 | The title of the embed. 429 | - `value` 430 | 431 | The text of the field 432 | ## Options 433 | - `inline` 434 | 435 | When setting this to `true`, up to 3 fields can appear side by side, 436 | given they are all inlined. 437 | ## Examples 438 | ```elixir 439 | %Embed{} 440 | |> field("Field1", "the best field!") 441 | |> field("Inline1", "look a field ->") 442 | |> field("Inline2", "<- look a field") 443 | ``` 444 | """ 445 | @spec field(Embed.t(), String.t(), String.t()) :: Embed.t() 446 | def field(embed, name, value, options \\ []) do 447 | field = 448 | %{name: name, value: value} 449 | |> Map.merge(Enum.into(options, %{})) 450 | 451 | %{embed | fields: embed.fields ++ [field]} 452 | end 453 | 454 | @doc """ 455 | Adds a thumbnail to an embed. 456 | 457 | ## Examples 458 | ```elixir 459 | %Embed{} 460 | |> thumbnail("http://i.imgur.com/4AiXzf8.jpg") 461 | ``` 462 | """ 463 | @spec thumbnail(Embed.t(), url) :: Embed.t() 464 | def thumbnail(embed, url) do 465 | %{embed | thumbnail: %{url: url}} 466 | end 467 | 468 | @doc """ 469 | Sets the main image of the embed. 470 | 471 | ## Examples 472 | ```elixir 473 | %Embed{} 474 | |> image("http://i.imgur.com/4AiXzf8.jpg") 475 | """ 476 | @spec image(Embed.t(), url) :: Embed.t() 477 | def image(embed, url) do 478 | %{embed | image: %{url: url}} 479 | end 480 | 481 | @doc """ 482 | Adds a timestamp to an embed. 483 | 484 | Note that the Datetime object will get converted to an `iso8601` formatted string. 485 | 486 | ## Examples 487 | %Embed{} |> timestamp(DateTime.utc_now()) 488 | """ 489 | @spec timestamp(Embed.t(), DateTime.t()) :: DateTime.t() 490 | def timestamp(embed, %DateTime{} = time) do 491 | %{embed | timestamp: DateTime.to_iso8601(time)} 492 | end 493 | 494 | @doc """ 495 | Sends an embed to the same channel as the message triggering a command. 496 | 497 | This macro can't be used outside of `Alchemy.Cogs` commands. 498 | 499 | See `Alchemy.Client.send_message/3` for a list of options that can be 500 | passed to this macro. 501 | ## Examples 502 | ```elixir 503 | Cogs.def blue do 504 | %Embed{} 505 | |> color(0x1d3ad1) 506 | |> description("Hello!") 507 | |> Embed.send("Here's an embed, and a file", file: "foo.txt") 508 | end 509 | ``` 510 | """ 511 | defmacro send(embed, content \\ "", options \\ []) do 512 | quote do 513 | Alchemy.Client.send_message( 514 | var!(message).channel_id, 515 | unquote(content), 516 | [{:embed, unquote(embed)} | unquote(options)] 517 | ) 518 | end 519 | end 520 | end 521 | --------------------------------------------------------------------------------