├── test └── test_helper.exs ├── example ├── .formatter.exs ├── config │ └── config.exs ├── lib │ ├── example.ex │ └── example │ │ └── bot.ex ├── mix.exs └── .gitignore ├── config └── config.exs ├── .formatter.exs ├── lib ├── coxir │ ├── voice │ │ ├── payload │ │ │ ├── hello.ex │ │ │ ├── resume.ex │ │ │ ├── session_description.ex │ │ │ ├── speaking.ex │ │ │ ├── ready.ex │ │ │ ├── identify.ex │ │ │ └── select_protocol.ex │ │ ├── payload.ex │ │ ├── audio.ex │ │ ├── session.ex │ │ └── instance.ex │ ├── gateway │ │ ├── payload │ │ │ ├── hello.ex │ │ │ ├── resume.ex │ │ │ ├── voice_server_update.ex │ │ │ ├── message_reaction_remove_all.ex │ │ │ ├── session_start_limit.ex │ │ │ ├── update_voice_state.ex │ │ │ ├── ready.ex │ │ │ ├── message_reaction_remove_emoji.ex │ │ │ ├── gateway_info.ex │ │ │ ├── update_presence.ex │ │ │ ├── request_guild_members.ex │ │ │ ├── guild_members_chunk.ex │ │ │ ├── identify.ex │ │ │ └── voice_instance_update.ex │ │ ├── stage │ │ │ ├── consumer.ex │ │ │ ├── handler.ex │ │ │ ├── producer.ex │ │ │ └── dispatcher.ex │ │ ├── intents.ex │ │ ├── payload.ex │ │ └── session.ex │ ├── api │ │ ├── helper.ex │ │ ├── headers.ex │ │ ├── error.ex │ │ └── ratelimiter.ex │ ├── model │ │ ├── entities │ │ │ ├── message │ │ │ │ ├── embed │ │ │ │ │ ├── provider.ex │ │ │ │ │ ├── field.ex │ │ │ │ │ ├── footer.ex │ │ │ │ │ ├── image.ex │ │ │ │ │ ├── video.ex │ │ │ │ │ ├── author.ex │ │ │ │ │ └── thumbnail.ex │ │ │ │ ├── reference.ex │ │ │ │ ├── attachment.ex │ │ │ │ ├── component.ex │ │ │ │ └── embed.ex │ │ │ ├── interaction │ │ │ │ ├── application_command_data.ex │ │ │ │ └── application_command_data_option.ex │ │ │ ├── presence │ │ │ │ └── activity.ex │ │ │ ├── interaction.ex │ │ │ ├── integration.ex │ │ │ ├── emoji.ex │ │ │ ├── webhook.ex │ │ │ ├── reaction.ex │ │ │ ├── presence.ex │ │ │ ├── user.ex │ │ │ ├── invite.ex │ │ │ ├── overwrite.ex │ │ │ ├── role.ex │ │ │ ├── voice_state.ex │ │ │ ├── ban.ex │ │ │ ├── message.ex │ │ │ ├── member.ex │ │ │ ├── guild.ex │ │ │ └── channel.ex │ │ ├── snowflake.ex │ │ ├── helper.ex │ │ └── loader.ex │ ├── adapters │ │ ├── sharder.ex │ │ ├── limiter │ │ │ ├── helper.ex │ │ │ └── default.ex │ │ ├── limiter.ex │ │ ├── sharder │ │ │ └── default.ex │ │ ├── player.ex │ │ ├── storage.ex │ │ ├── storage │ │ │ └── default.ex │ │ └── player │ │ │ └── default.ex │ ├── token.ex │ ├── api.ex │ ├── model.ex │ ├── voice.ex │ └── gateway.ex └── coxir.ex ├── guides ├── introduction.md ├── quickstart.md ├── entities.md ├── multiple-clients.md └── configuration.md ├── .gitignore ├── .github └── workflows │ ├── validation.yml │ └── documentation.yml ├── README.md ├── mix.exs ├── mix.lock └── LICENSE /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /example/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config :tesla, Coxir.API, adapter: Tesla.Mock 4 | 5 | config :porcelain, driver: Porcelain.Driver.Basic 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["example"] 5 | ] 6 | -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :porcelain, driver: Porcelain.Driver.Basic 4 | 5 | config :coxir, 6 | token: "", 7 | intents: :non_privileged 8 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload/hello.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload.Hello do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Voice.Payload 6 | 7 | embedded_schema do 8 | field(:heartbeat_interval, :float) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/hello.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.Hello do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:heartbeat_interval, :integer) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload/resume.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload.Resume do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Voice.Payload 6 | 7 | embedded_schema do 8 | field(:server_id, Snowflake) 9 | field(:session_id, :string) 10 | field(:token, :string) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload/session_description.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload.SessionDescription do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Voice.Payload 6 | 7 | embedded_schema do 8 | field(:mode, :string) 9 | field(:secret_key, {:array, :integer}) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload/speaking.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload.Speaking do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Voice.Payload 6 | 7 | embedded_schema do 8 | field(:speaking, :integer) 9 | field(:delay, :integer) 10 | field(:ssrc, :integer) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/resume.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.Resume do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:token, :string) 9 | field(:session_id, :string) 10 | field(:sequence, :integer) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload/ready.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload.Ready do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Voice.Payload 6 | 7 | embedded_schema do 8 | field(:ssrc, :integer) 9 | field(:ip, :string) 10 | field(:port, :integer) 11 | field(:modes, {:array, :string}) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/coxir/api/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.API.Helper do 2 | @moduledoc """ 3 | Common helper functions for `Coxir.API` middlewares. 4 | """ 5 | alias Tesla.Env 6 | alias Coxir.Token 7 | 8 | @spec get_token(Env.t()) :: Token.t() 9 | def get_token(%Env{opts: options}) do 10 | Token.from_options!(options) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/voice_server_update.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.VoiceServerUpdate do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:token, :string) 9 | field(:endpoint, :string) 10 | 11 | belongs_to(:guild, Guild) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /example/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | use Application 3 | 4 | alias Example.Bot 5 | 6 | def start(_type, _args) do 7 | children = [ 8 | Bot 9 | ] 10 | 11 | options = [ 12 | strategy: :one_for_one, 13 | name: __MODULE__ 14 | ] 15 | 16 | Supervisor.start_link(children, options) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload/identify.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload.Identify do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Voice.Payload 6 | 7 | embedded_schema do 8 | field(:server_id, Snowflake) 9 | field(:user_id, Snowflake) 10 | field(:session_id, :string) 11 | field(:token, :string) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/message_reaction_remove_all.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.MessageReactionRemoveAll do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | belongs_to(:channel, Channel) 9 | belongs_to(:message, Message) 10 | belongs_to(:guild, Guild) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed/provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed.Provider do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Provider{} 10 | 11 | embedded_schema do 12 | field(:name, :string) 13 | field(:url, :string) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/session_start_limit.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.SessionStartLimit do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:total, :integer) 9 | field(:remaining, :integer) 10 | field(:reset_after, :integer) 11 | field(:max_concurrency, :integer) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/update_voice_state.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.UpdateVoiceState do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:self_mute, :boolean) 9 | field(:self_deaf, :boolean) 10 | 11 | field(:guild_id, Snowflake) 12 | field(:channel_id, Snowflake) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed/field.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed.Field do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Field{} 10 | 11 | embedded_schema do 12 | field(:name, :string) 13 | field(:value, :string) 14 | field(:inline, :boolean) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/ready.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.Ready do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:v, :integer) 9 | field(:session_id, :string) 10 | field(:shard, {:array, :integer}) 11 | 12 | embeds_many(:guilds, Guild) 13 | 14 | belongs_to(:user, User) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed/footer.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed.Footer do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Footer{} 10 | 11 | embedded_schema do 12 | field(:text, :string) 13 | field(:icon_url, :string) 14 | field(:proxy_icon_url, :string) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/message_reaction_remove_emoji.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.MessageReactionRemoveEmoji do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | embeds_one(:emoji, Emoji) 9 | 10 | belongs_to(:channel, Channel) 11 | belongs_to(:message, Message) 12 | belongs_to(:guild, Guild) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/gateway_info.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.GatewayInfo do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | alias Coxir.Gateway.Payload.SessionStartLimit 8 | 9 | embedded_schema do 10 | field(:url, :string) 11 | field(:shards, :integer) 12 | 13 | embeds_one(:session_start_limit, SessionStartLimit) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/update_presence.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.UpdatePresence do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | alias Coxir.Presence.Activity 8 | 9 | embedded_schema do 10 | field(:since, :integer) 11 | field(:status, :string) 12 | field(:afk, :boolean) 13 | 14 | embeds_many(:activities, Activity) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed/image.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed.Image do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Image{} 10 | 11 | embedded_schema do 12 | field(:url, :string) 13 | field(:proxy_url, :string) 14 | field(:height, :integer) 15 | field(:width, :integer) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed/video.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed.Video do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Video{} 10 | 11 | embedded_schema do 12 | field(:url, :string) 13 | field(:proxy_url, :string) 14 | field(:height, :integer) 15 | field(:width, :integer) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed/author.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed.Author do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Author{} 10 | 11 | embedded_schema do 12 | field(:name, :string) 13 | field(:url, :string) 14 | field(:icon_url, :string) 15 | field(:proxy_icon_url, :string) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed/thumbnail.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed.Thumbnail do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Thumbnail{} 10 | 11 | embedded_schema do 12 | field(:url, :string) 13 | field(:proxy_url, :string) 14 | field(:height, :integer) 15 | field(:width, :integer) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/reference.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Reference do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Reference{} 10 | 11 | embedded_schema do 12 | belongs_to(:message, Message, primary_key: true) 13 | belongs_to(:channel, Channel, primary_key: true) 14 | belongs_to(:guild, Guild) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/request_guild_members.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.RequestGuildMembers do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:guild_id, Snowflake) 9 | field(:query, :string) 10 | field(:limit, :integer) 11 | field(:presences, :boolean) 12 | field(:user_ids, {:array, Snowflake}) 13 | field(:nonce, :string) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | mod: {Example, []} 17 | ] 18 | end 19 | 20 | defp deps do 21 | [ 22 | {:coxir, path: "../"} 23 | ] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/attachment.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Attachment do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @type t :: %Attachment{} 8 | 9 | embedded_schema do 10 | field(:filename, :string) 11 | field(:content_type, :string) 12 | field(:size, :integer) 13 | field(:url, :string) 14 | field(:proxy_url, :string) 15 | field(:height, :integer) 16 | field(:width, :integer) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/interaction/application_command_data.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Interaction.ApplicationCommandData do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | alias Coxir.Interaction.ApplicationCommandDataOption 8 | 9 | @primary_key false 10 | 11 | @type t :: %ApplicationCommandData{} 12 | 13 | embedded_schema do 14 | field(:name, :string) 15 | 16 | embeds_many(:options, ApplicationCommandDataOption) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/coxir/adapters/sharder.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Sharder do 2 | @moduledoc """ 3 | Handles how gateway shards are started. 4 | """ 5 | alias Coxir.Gateway.Session 6 | 7 | @type sharder :: pid 8 | 9 | defstruct [ 10 | :shard_count, 11 | :session_options 12 | ] 13 | 14 | @type t :: module 15 | 16 | @type options :: %__MODULE__{} 17 | 18 | @callback child_spec(options) :: Supervisor.child_spec() 19 | 20 | @callback get_shard(sharder, non_neg_integer) :: Session.session() 21 | end 22 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/guild_members_chunk.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.GuildMembersChunk do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | embedded_schema do 8 | field(:chunk_index, :integer) 9 | field(:chunk_count, :integer) 10 | field(:not_found, {:array, Snowflake}) 11 | field(:nonce, :string) 12 | 13 | embeds_many(:members, Member) 14 | embeds_many(:presences, Presence) 15 | 16 | belongs_to(:guild, Guild) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/component.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Component do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @type t :: %Component{} 8 | 9 | embedded_schema do 10 | field(:type, :integer) 11 | field(:style, :integer) 12 | field(:label, :string) 13 | field(:custom_id, :string) 14 | field(:url, :string) 15 | field(:disabled, :boolean) 16 | 17 | embeds_one(:emoji, Emoji) 18 | 19 | embeds_many(:components, Component) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/interaction/application_command_data_option.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Interaction.ApplicationCommandDataOption do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | alias __MODULE__ 8 | 9 | @primary_key false 10 | 11 | @type t :: %ApplicationCommandDataOption{} 12 | 13 | embedded_schema do 14 | field(:name, :string) 15 | field(:type, :integer) 16 | field(:value, :integer) 17 | 18 | embeds_many(:options, ApplicationCommandDataOption) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /guides/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | coxir is a modern high-level Elixir wrapper for [Discord](https://discord.com). 4 | 5 | ### Features 6 | 7 | - Support for running multiple bots in a same application 8 | - Configurable adapters that change how the library behaves: 9 | - **Limiter:** handles how rate limit buckets are stored 10 | - **Storage:** handles how entities are cached 11 | - **Sharder:** handles how shards are started 12 | - **Player:** handles the audio sent through voice 13 | - Easy-to-use syntax for interacting with Discord entities 14 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/identify.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.Identify do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Gateway.Payload 6 | 7 | @properties %{ 8 | "$browser" => "coxir", 9 | "$device" => "coxir" 10 | } 11 | 12 | embedded_schema do 13 | field(:token, :string) 14 | field(:properties, :map, default: @properties) 15 | field(:compress, :boolean) 16 | field(:large_threshold, :integer, default: 250) 17 | field(:shard, {:array, :integer}) 18 | field(:intents, :integer) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/coxir.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir do 2 | @moduledoc """ 3 | Entry-point for the coxir application. 4 | 5 | Starts a supervisor with the components required by the library. 6 | """ 7 | use Application 8 | 9 | alias Coxir.{Limiter, Storage, Voice} 10 | 11 | @doc false 12 | def start(_type, _args) do 13 | children = [ 14 | Limiter, 15 | Storage, 16 | Voice 17 | ] 18 | 19 | options = [ 20 | strategy: :one_for_one, 21 | name: __MODULE__ 22 | ] 23 | 24 | Supervisor.start_link(children, options) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/presence/activity.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Presence.Activity do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @derive Jason.Encoder 10 | 11 | @type t :: %Activity{} 12 | 13 | embedded_schema do 14 | field(:name, :string) 15 | field(:type, :integer) 16 | field(:url, :string) 17 | field(:created_at, :utc_datetime) 18 | field(:application_id, Snowflake) 19 | field(:details, :string) 20 | field(:state, :string) 21 | field(:instance, :boolean) 22 | field(:flags, :integer) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/interaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Interaction do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | alias Coxir.Interaction.ApplicationCommandData 8 | 9 | @type t :: %Interaction{} 10 | 11 | embedded_schema do 12 | field(:application_id, Snowflake) 13 | field(:type, :integer) 14 | field(:token, :string) 15 | field(:version, :integer) 16 | 17 | embeds_one(:data, ApplicationCommandData) 18 | embeds_one(:member, Member) 19 | 20 | belongs_to(:user, User) 21 | belongs_to(:channel, Channel) 22 | belongs_to(:guild, Guild) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload/select_protocol.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload.SelectProtocol do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Voice.Payload 6 | 7 | alias Coxir.Voice.Payload.SelectProtocol.Data 8 | 9 | embedded_schema do 10 | field(:protocol, :string, default: "udp") 11 | 12 | embeds_one(:data, Data) 13 | end 14 | end 15 | 16 | defmodule Coxir.Voice.Payload.SelectProtocol.Data do 17 | @moduledoc """ 18 | Work in progress. 19 | """ 20 | use Coxir.Voice.Payload 21 | 22 | embedded_schema do 23 | field(:address, :string) 24 | field(:port, :integer) 25 | field(:mode, :string) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/coxir/api/headers.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.API.Headers do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | @behaviour Tesla.Middleware 6 | 7 | import Coxir.API.Helper 8 | 9 | alias Tesla.Middleware.Headers 10 | 11 | @project Coxir.MixProject.project() 12 | @website @project[:source_url] 13 | @version @project[:version] 14 | @library @project[:name] 15 | 16 | @agent "#{@library} (#{@website}, #{@version})" 17 | 18 | def call(request, next, _options) do 19 | token = get_token(request) 20 | 21 | headers = [ 22 | {"User-Agent", @agent}, 23 | {"Authorization", "Bot " <> token} 24 | ] 25 | 26 | Headers.call(request, next, headers) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/coxir/gateway/stage/consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Consumer do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use ConsumerSupervisor 6 | 7 | alias Coxir.Gateway.Handler 8 | alias __MODULE__ 9 | 10 | defstruct [ 11 | :dispatcher, 12 | :handler 13 | ] 14 | 15 | def start_link(state) do 16 | ConsumerSupervisor.start_link(__MODULE__, state) 17 | end 18 | 19 | def init(%Consumer{dispatcher: dispatcher, handler: handler}) do 20 | children = [ 21 | {Handler, handler} 22 | ] 23 | 24 | options = [ 25 | strategy: :one_for_one, 26 | subscribe_to: [dispatcher] 27 | ] 28 | 29 | ConsumerSupervisor.init(children, options) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload/voice_instance_update.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload.VoiceInstanceUpdate do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | alias Coxir.Model.Snowflake 6 | alias Coxir.Voice.Instance 7 | 8 | @type t :: %__MODULE__{ 9 | instance: Instance.instance(), 10 | user_id: Snowflake.t(), 11 | guild_id: Snowflake.t() | nil, 12 | channel_id: Snowflake.t(), 13 | has_player?: boolean, 14 | invalid?: boolean, 15 | playing?: boolean 16 | } 17 | 18 | defstruct [ 19 | :instance, 20 | :user_id, 21 | :guild_id, 22 | :channel_id, 23 | :has_player?, 24 | :invalid?, 25 | :playing? 26 | ] 27 | end 28 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/integration.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Integration do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | @type t :: %Integration{} 8 | 9 | embedded_schema do 10 | field(:name, :string) 11 | field(:type, :string) 12 | field(:enabled, :boolean) 13 | field(:syncing, :boolean) 14 | field(:enable_emoticons, :boolean) 15 | field(:expire_behavior, :integer) 16 | field(:expire_grace_period, :integer) 17 | field(:synced_at, :utc_datetime) 18 | field(:subscriber_count, :integer) 19 | field(:revoked, :boolean) 20 | 21 | belongs_to(:guild, Guild, primary_key: true) 22 | belongs_to(:role, Role) 23 | belongs_to(:user, User) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/coxir/api/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.API.Error do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | alias __MODULE__ 6 | 7 | @type t :: %Error{} 8 | 9 | defexception [ 10 | :status, 11 | :code, 12 | :message 13 | ] 14 | 15 | def cast(status, %{"code" => code, "message" => message}) do 16 | %Error{status: status, code: code, message: message} 17 | end 18 | 19 | def cast(status, _term) do 20 | %Error{status: status} 21 | end 22 | 23 | def message(%Error{status: status, code: nil}) do 24 | reason = :httpd_util.reason_phrase(status) 25 | "(#{status}) #{reason}" 26 | end 27 | 28 | def message(%Error{status: status, code: code, message: message}) do 29 | "(#{status}) #{code} - #{message}" 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /example/.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 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | example-*.tar 24 | 25 | # Temporary files for e.g. tests 26 | /tmp 27 | 28 | # Custom ignored files 29 | mix.lock 30 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/emoji.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Emoji do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @type t :: %Emoji{} 8 | 9 | embedded_schema do 10 | field(:name, :string) 11 | field(:roles, {:array, Snowflake}) 12 | field(:require_colons, :boolean) 13 | field(:managed, :boolean) 14 | field(:animated, :boolean) 15 | field(:available, :boolean) 16 | 17 | belongs_to(:user, User) 18 | end 19 | 20 | def format(%Emoji{id: nil, name: name}) do 21 | name 22 | end 23 | 24 | def format(%Emoji{id: id, name: name}) do 25 | "#{name}:#{id}" 26 | end 27 | 28 | defimpl String.Chars do 29 | @spec to_string(Emoji.t()) :: binary 30 | defdelegate to_string(emoji), to: Emoji, as: :format 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/coxir/adapters/limiter/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Limiter.Helper do 2 | @moduledoc """ 3 | Common helper functions for `Coxir.Limiter` implementations. 4 | """ 5 | alias Coxir.Limiter 6 | 7 | @spec time_now() :: non_neg_integer 8 | def time_now do 9 | DateTime.to_unix(DateTime.utc_now(), :millisecond) 10 | end 11 | 12 | @spec offset_now(non_neg_integer) :: non_neg_integer 13 | def offset_now(offset \\ 0) do 14 | time_now() + offset + offset_noise() 15 | end 16 | 17 | @spec wait_hit(Limiter.bucket()) :: :ok 18 | def wait_hit(bucket) do 19 | with {:error, timeout} <- Limiter.hit(bucket) do 20 | Process.sleep(timeout + offset_noise()) 21 | wait_hit(bucket) 22 | end 23 | end 24 | 25 | defp offset_noise do 26 | trunc(:rand.uniform() * 500) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.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 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | coxir-*.tar 24 | 25 | # Temporary files for e.g. tests 26 | /tmp 27 | 28 | # Custom ignored files 29 | .tool-versions 30 | .elixir_ls 31 | .DS_Store 32 | .test 33 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message/embed.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message.Embed do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | alias Coxir.Message.Embed.{Footer, Image, Thumbnail, Video, Provider, Author, Field} 8 | 9 | @primary_key false 10 | 11 | @type t :: %Embed{} 12 | 13 | embedded_schema do 14 | field(:title, :string) 15 | field(:type, :string) 16 | field(:description, :string) 17 | field(:url, :string) 18 | field(:timestamp, :utc_datetime) 19 | field(:color, :integer) 20 | 21 | embeds_one(:footer, Footer) 22 | embeds_one(:image, Image) 23 | embeds_one(:thumbnail, Thumbnail) 24 | embeds_one(:vdeo, Video) 25 | embeds_one(:provider, Provider) 26 | embeds_one(:author, Author) 27 | 28 | embeds_many(:fields, Field) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/coxir/model/snowflake.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Model.Snowflake do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Ecto.Type 6 | 7 | @type t :: non_neg_integer 8 | 9 | defguard is_snowflake(term) when is_integer(term) and term in 0..0xFFFFFFFFFFFFFFFF 10 | 11 | def type do 12 | :integer 13 | end 14 | 15 | def cast(integer) when is_snowflake(integer) do 16 | {:ok, integer} 17 | end 18 | 19 | def cast(string) when is_binary(string) do 20 | case Integer.parse(string) do 21 | {integer, ""} -> 22 | cast(integer) 23 | 24 | _other -> 25 | :error 26 | end 27 | end 28 | 29 | def cast(_term) do 30 | :error 31 | end 32 | 33 | def load(term) do 34 | cast(term) 35 | end 36 | 37 | def dump(integer) when is_snowflake(integer) do 38 | {:ok, integer} 39 | end 40 | 41 | def dump(_term) do 42 | :error 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/webhook.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Webhook do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | @type t :: %Webhook{} 8 | 9 | embedded_schema do 10 | field(:type, :integer) 11 | field(:name, :string) 12 | field(:avatar, :string) 13 | field(:token, :string) 14 | 15 | belongs_to(:channel, Channel) 16 | belongs_to(:guild, Guild) 17 | belongs_to(:user, User) 18 | belongs_to(:source_guild, Guild) 19 | belongs_to(:source_channel, Channel) 20 | end 21 | 22 | def fetch(id, options) do 23 | API.get("webhooks/#{id}", options) 24 | end 25 | 26 | def insert(%{channel_id: channel_id} = params, options) do 27 | API.post("channels/#{channel_id}/webhooks", params, options) 28 | end 29 | 30 | def patch(id, params, options) do 31 | API.patch("webhooks/#{id}", params, options) 32 | end 33 | 34 | def drop(id, options) do 35 | API.delete("webhooks/#{id}", options) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/reaction.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Reaction do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Reaction{} 10 | 11 | embedded_schema do 12 | embeds_one(:member, Member) 13 | embeds_one(:emoji, Emoji) 14 | 15 | belongs_to(:message, Message) 16 | belongs_to(:channel, Channel) 17 | belongs_to(:guild, Guild) 18 | belongs_to(:user, User) 19 | end 20 | 21 | def insert(%{message_id: message_id, channel_id: channel_id, emoji: emoji}, options) do 22 | API.put("channels/#{channel_id}/messages/#{message_id}/reactions/#{emoji}/@me", options) 23 | end 24 | 25 | def delete( 26 | %Reaction{message_id: message_id, channel_id: channel_id, user_id: user_id, emoji: emoji}, 27 | options 28 | ) do 29 | API.delete( 30 | "channels/#{channel_id}/messages/#{message_id}/reactions/#{emoji}/#{user_id}", 31 | options 32 | ) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/coxir/adapters/limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Limiter do 2 | @moduledoc """ 3 | Handles how rate limit buckets are stored. 4 | """ 5 | @type bucket :: :global | String.t() 6 | 7 | @type limit :: non_neg_integer 8 | 9 | @type reset :: non_neg_integer 10 | 11 | @callback child_spec(term) :: Supervisor.child_spec() 12 | 13 | @callback put(bucket, limit, reset) :: :ok 14 | 15 | @callback hit(bucket) :: :ok | {:error, timeout} 16 | 17 | defmacro __using__(_options) do 18 | quote location: :keep do 19 | @behaviour Coxir.Limiter 20 | 21 | import Coxir.Limiter.Helper 22 | end 23 | end 24 | 25 | @doc false 26 | def child_spec(term) do 27 | limiter().child_spec(term) 28 | end 29 | 30 | @doc false 31 | def put(bucket, limit, reset) do 32 | limiter().put(bucket, limit, reset) 33 | end 34 | 35 | @doc false 36 | def hit(bucket) do 37 | limiter().hit(bucket) 38 | end 39 | 40 | defp limiter do 41 | Application.get_env(:coxir, :limiter, Coxir.Limiter.Default) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/coxir/gateway/stage/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Handler do 2 | @moduledoc """ 3 | Behaviour for modules handling events from `Coxir.Gateway.Consumer`. 4 | """ 5 | alias Coxir.Gateway.Dispatcher 6 | 7 | @typedoc """ 8 | A module that implements the behaviour or an anonymous function. 9 | """ 10 | @type handler :: module | (Dispatcher.event() -> any) 11 | 12 | @doc """ 13 | Called when a `t:Coxir.Gateway.Dispatcher.event/0` is to be handled. 14 | """ 15 | @callback handle_event(Dispatcher.event()) :: any 16 | 17 | @doc false 18 | def child_spec(handler) do 19 | %{ 20 | id: __MODULE__, 21 | start: {__MODULE__, :start_handler, [handler]}, 22 | restart: :temporary 23 | } 24 | end 25 | 26 | @doc false 27 | def start_handler(handler, event) when is_function(handler) do 28 | Task.start_link(fn -> 29 | handler.(event) 30 | end) 31 | end 32 | 33 | def start_handler(handler, event) when is_atom(handler) do 34 | Task.start_link(fn -> 35 | handler.handle_event(event) 36 | end) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/coxir/adapters/sharder/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Sharder.Default do 2 | @moduledoc """ 3 | Starts all shards locally. 4 | """ 5 | use Supervisor 6 | 7 | alias Coxir.Sharder 8 | alias Coxir.Gateway.Session 9 | 10 | def start_link(options) do 11 | Supervisor.start_link(__MODULE__, options) 12 | end 13 | 14 | def init(%Sharder{shard_count: shard_count, session_options: session_options}) do 15 | children = 16 | for index <- 1..shard_count do 17 | shard = [index - 1, shard_count] 18 | options = %{session_options | shard: shard} 19 | session_spec = Session.child_spec(options) 20 | %{session_spec | id: index - 1} 21 | end 22 | 23 | options = [ 24 | strategy: :one_for_one 25 | ] 26 | 27 | Supervisor.init(children, options) 28 | end 29 | 30 | def get_shard(sharder, index) do 31 | children = Supervisor.which_children(sharder) 32 | 33 | Enum.find_value( 34 | children, 35 | fn {id, pid, _type, _modules} -> 36 | if id == index, do: pid 37 | end 38 | ) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/coxir/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Token do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | alias Coxir.Model.Snowflake 6 | alias Coxir.API.Error 7 | alias Coxir.Gateway 8 | 9 | @type t :: String.t() 10 | 11 | @spec get_user_id(t) :: Snowflake.t() 12 | def get_user_id(token) do 13 | {:ok, user_id} = 14 | token 15 | |> String.split(".") 16 | |> List.first() 17 | |> Base.decode64!() 18 | |> Snowflake.cast() 19 | 20 | user_id 21 | end 22 | 23 | @spec from_options(Enum.t()) :: t | nil 24 | def from_options(options) when is_list(options) do 25 | options 26 | |> Map.new() 27 | |> from_options() 28 | end 29 | 30 | def from_options(%{as: gateway}) do 31 | Gateway.get_token(gateway) 32 | end 33 | 34 | def from_options(options) do 35 | config = Application.get_env(:coxir, :token) 36 | Map.get(options, :token, config) 37 | end 38 | 39 | @spec from_options!(Enum.t()) :: t 40 | def from_options!(options) do 41 | with nil <- from_options(options) do 42 | raise(Error, status: 401) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.github/workflows/validation.yml: -------------------------------------------------------------------------------- 1 | name: Validation 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | MIX_ENV: test 7 | 8 | jobs: 9 | validation: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Install Elixir 15 | uses: erlef/setup-beam@v1 16 | id: elixir 17 | with: 18 | otp-version: "23.0" 19 | elixir-version: "1.11.4" 20 | - name: Formatter 21 | run: mix format --check-formatted 22 | - name: Cache dependencies 23 | uses: actions/cache@v2 24 | id: cache 25 | with: 26 | path: | 27 | deps 28 | _build 29 | key: ${{ runner.os }}-${{ steps.elixir.outputs.elixir-version }}-${{ steps.elixir.outputs.otp-version }}-${{ hashFiles('mix.lock') }}-${{ env.MIX_ENV }} 30 | - name: Install dependencies 31 | if: steps.cache.outputs.cache-hit != 'true' 32 | run: mix do deps.get, deps.compile 33 | - name: Compile 34 | run: mix compile 35 | - name: Run tests 36 | run: mix test 37 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Presence do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | alias Coxir.Presence.Activity 8 | 9 | @primary_key false 10 | 11 | @type t :: %Presence{} 12 | 13 | embedded_schema do 14 | field(:status, :string) 15 | 16 | field(:member, :any, virtual: true) 17 | 18 | embeds_many(:activities, Activity) 19 | 20 | belongs_to(:user, User, primary_key: true) 21 | belongs_to(:guild, Guild, primary_key: true) 22 | end 23 | 24 | def preload(%Presence{member: %Member{}} = presence, :member, options) do 25 | if options[:force] do 26 | presence = %{presence | member: nil} 27 | preload(presence, :member, options) 28 | else 29 | presence 30 | end 31 | end 32 | 33 | def preload(%Presence{user_id: user_id, guild_id: guild_id} = presence, :member, options) do 34 | member = Member.get({user_id, guild_id}, options) 35 | %{presence | member: member} 36 | end 37 | 38 | def preload(presence, association, options) do 39 | super(presence, association, options) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.User do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | alias Coxir.Token 8 | 9 | @type t :: %User{} 10 | 11 | embedded_schema do 12 | field(:username, :string) 13 | field(:discriminator, :string) 14 | field(:avatar, :string) 15 | field(:bot, :boolean) 16 | field(:system, :boolean) 17 | field(:mfa_enabled, :boolean) 18 | field(:locale, :string) 19 | field(:verified, :boolean) 20 | field(:email, :string) 21 | field(:flags, :integer) 22 | field(:premium_type, :integer) 23 | field(:public_flags, :integer) 24 | end 25 | 26 | def fetch(id, options) do 27 | API.get("users/#{id}", options) 28 | end 29 | 30 | @spec get_me(Loader.options()) :: User.t() | Error.t() 31 | def get_me(options \\ []) do 32 | options 33 | |> Token.from_options!() 34 | |> Token.get_user_id() 35 | |> get(options) 36 | end 37 | 38 | @spec create_dm(t, Loader.options()) :: Loader.result() 39 | def create_dm(%User{id: id}, options \\ []) do 40 | params = %{recipient_id: id} 41 | Channel.create(params, options) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/invite.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Invite do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @type t :: %Invite{} 10 | 11 | embedded_schema do 12 | field(:code, :string, primary_key: true) 13 | field(:uses, :integer) 14 | field(:max_uses, :integer) 15 | field(:max_age, :integer) 16 | field(:temporary, :boolean) 17 | field(:target_type, :integer) 18 | field(:approximate_presence_count, :integer) 19 | field(:approximate_member_count, :integer) 20 | field(:created_at, :utc_datetime) 21 | field(:expires_at, :utc_datetime) 22 | 23 | belongs_to(:guild, Guild) 24 | belongs_to(:channel, Channel) 25 | belongs_to(:inviter, User) 26 | belongs_to(:target_user, User) 27 | end 28 | 29 | def fetch(code, options) do 30 | query = Keyword.take(options, [:with_counts, :with_expiration]) 31 | API.get("invites/#{code}", query, options) 32 | end 33 | 34 | def insert(%{channel_id: channel_id} = params, options) do 35 | API.post("channels/#{channel_id}/invites", params, options) 36 | end 37 | 38 | def drop(code, options) do 39 | API.delete("invites/#{code}", options) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/overwrite.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Overwrite do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | @type t :: %Overwrite{} 8 | 9 | embedded_schema do 10 | field(:type, :integer) 11 | field(:allow, :integer) 12 | field(:deny, :integer) 13 | 14 | belongs_to(:channel, Channel, primary_key: true) 15 | end 16 | 17 | def fetch({id, channel_id}, options) do 18 | overwrite = 19 | %Channel{id: channel_id} 20 | |> Channel.preload(:permission_overwrites, options) 21 | |> Map.get(:permission_overwrites) 22 | |> Enum.find(&(&1.id == id)) 23 | 24 | if not is_nil(overwrite) do 25 | {:ok, overwrite} 26 | else 27 | {:error, %Error{status: 404}} 28 | end 29 | end 30 | 31 | def insert(%{id: id, channel_id: channel_id} = params, options) do 32 | overwrite = %Overwrite{id: id, channel_id: channel_id} 33 | update(overwrite, params, options) 34 | end 35 | 36 | def patch({id, channel_id}, params, options) do 37 | API.put("channels/#{channel_id}/permissions/#{id}", params, options) 38 | end 39 | 40 | def drop({id, channel_id}, options) do 41 | API.delete("channels/#{channel_id}/permissions/#{id}", options) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /example/lib/example/bot.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Bot do 2 | use Coxir.Gateway 3 | 4 | require Logger 5 | 6 | alias Coxir.Gateway.Payload.Ready 7 | alias Coxir.{User, Guild, Channel, Message} 8 | 9 | def handle_event({:READY, ready}) do 10 | %Ready{shard: [shard, _shard_count], user: user} = ready 11 | 12 | %User{username: username, discriminator: discriminator} = user 13 | 14 | Logger.info("Shard ##{shard} ready for user #{username}##{discriminator}.") 15 | 16 | update_presence(activities: [%{type: 0, name: "with coxir!"}]) 17 | end 18 | 19 | def handle_event({:MESSAGE_CREATE, message}) do 20 | message = Message.preload(message, [:author, channel: :guild]) 21 | 22 | %Message{content: content, author: author, channel: channel} = message 23 | 24 | %User{username: username, discriminator: discriminator} = author 25 | 26 | %Channel{name: channel_name, guild: guild} = channel 27 | 28 | where = 29 | with %Guild{name: guild_name} <- guild do 30 | "[#{guild_name}] [##{channel_name}]" 31 | else 32 | _other -> "[DM]" 33 | end 34 | 35 | Logger.info("#{where} #{username}##{discriminator}: #{content}") 36 | end 37 | 38 | def handle_event(_event) do 39 | :noop 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/coxir/voice/payload.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Payload do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | alias __MODULE__ 6 | 7 | @operations %{ 8 | 0 => :IDENTIFY, 9 | 1 => :SELECT_PROTOCOL, 10 | 2 => :READY, 11 | 3 => :HEARTBEAT, 12 | 4 => :SESSION_DESCRIPTION, 13 | 5 => :SPEAKING, 14 | 6 => :HEARTBEAT_ACK, 15 | 7 => :RESUME, 16 | 8 => :HELLO, 17 | 9 => :RESUMED 18 | } 19 | @codes Map.new(@operations, fn {key, value} -> {value, key} end) 20 | 21 | defstruct [ 22 | :operation, 23 | :data 24 | ] 25 | 26 | @type t :: %Payload{} 27 | 28 | defmacro __using__(_options) do 29 | quote location: :keep do 30 | use Coxir.Model, storable?: false 31 | 32 | @primary_key false 33 | 34 | @derive Jason.Encoder 35 | 36 | def cast(object) do 37 | Coxir.Model.Loader.load(__MODULE__, object) 38 | end 39 | end 40 | end 41 | 42 | def cast(%{"op" => opcode, "d" => data}) do 43 | operation = Map.get(@operations, opcode, opcode) 44 | %Payload{operation: operation, data: data} 45 | end 46 | 47 | def to_command(%Payload{operation: operation, data: data}) do 48 | opcode = Map.fetch!(@codes, operation) 49 | %{"op" => opcode, "d" => data} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | MIX_ENV: dev 10 | 11 | jobs: 12 | documentation: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | - name: Install Elixir 18 | uses: erlef/setup-beam@v1 19 | id: elixir 20 | with: 21 | otp-version: "23.0" 22 | elixir-version: "1.11.4" 23 | - name: Cache dependencies 24 | uses: actions/cache@v2 25 | id: cache 26 | with: 27 | path: | 28 | deps 29 | _build 30 | key: ${{ runner.os }}-${{ steps.elixir.outputs.elixir-version }}-${{ steps.elixir.outputs.otp-version }}-${{ hashFiles('mix.lock') }}-${{ env.MIX_ENV }} 31 | - name: Install dependencies 32 | if: steps.cache.outputs.cache-hit != 'true' 33 | run: mix do deps.get, deps.compile 34 | - name: Compile 35 | run: mix compile 36 | - name: Generate documentation 37 | run: mix docs 38 | - name: Publish documentation 39 | uses: peaceiris/actions-gh-pages@v3 40 | with: 41 | github_token: ${{ secrets.GITHUB_TOKEN }} 42 | publish_dir: ./doc 43 | -------------------------------------------------------------------------------- /lib/coxir/gateway/stage/producer.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Producer do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use GenStage 6 | 7 | alias GenStage.BroadcastDispatcher 8 | 9 | @type producer :: pid 10 | 11 | def start_link(state) do 12 | GenStage.start_link(__MODULE__, state) 13 | end 14 | 15 | def init(_state) do 16 | state = {:queue.new(), 0} 17 | {:producer, state, dispatcher: BroadcastDispatcher} 18 | end 19 | 20 | def handle_demand(requested, {queue, demand}) do 21 | dispatch(queue, demand + requested) 22 | end 23 | 24 | def handle_cast({:notify, event}, {queue, demand}) do 25 | queue = :queue.in(event, queue) 26 | dispatch(queue, demand) 27 | end 28 | 29 | def notify(producer, event) do 30 | GenStage.cast(producer, {:notify, event}) 31 | end 32 | 33 | defp dispatch(demand, queue, events \\ []) 34 | 35 | defp dispatch(queue, 0, events) do 36 | events = Enum.reverse(events) 37 | {:noreply, events, {queue, 0}} 38 | end 39 | 40 | defp dispatch(queue, demand, events) do 41 | case :queue.out(queue) do 42 | {{:value, event}, queue} -> 43 | events = [event | events] 44 | dispatch(queue, demand - 1, events) 45 | 46 | _other -> 47 | events = Enum.reverse(events) 48 | {:noreply, events, {queue, demand}} 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/role.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Role do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | @type t :: %Role{} 8 | 9 | embedded_schema do 10 | field(:name, :string) 11 | field(:color, :integer) 12 | field(:hoist, :boolean) 13 | field(:position, :integer) 14 | field(:permissions, :integer) 15 | field(:managed, :boolean) 16 | field(:mentionable, :boolean) 17 | 18 | belongs_to(:guild, Guild, primary_key: true) 19 | end 20 | 21 | def fetch({id, guild_id}, options) do 22 | role = 23 | %Guild{id: guild_id} 24 | |> Guild.preload(:roles, options) 25 | |> Map.get(:roles) 26 | |> Enum.find(&(&1.id == id)) 27 | 28 | if not is_nil(role) do 29 | {:ok, role} 30 | else 31 | {:error, %Error{status: 404}} 32 | end 33 | end 34 | 35 | def insert(%{guild_id: guild_id} = params, options) do 36 | with {:ok, object} <- API.post("guilds/#{guild_id}/roles", params, options) do 37 | object = Map.put(object, "guild_id", guild_id) 38 | {:ok, object} 39 | end 40 | end 41 | 42 | def patch({id, guild_id}, params, options) do 43 | API.patch("guilds/#{guild_id}/roles/#{id}", params, options) 44 | end 45 | 46 | def drop({id, guild_id}, options) do 47 | API.delete("guilds/#{guild_id}/roles/#{id}", options) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/voice_state.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.VoiceState do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | @primary_key false 8 | 9 | @type t :: %VoiceState{} 10 | 11 | embedded_schema do 12 | field(:session_id, :string) 13 | field(:deaf, :boolean) 14 | field(:mute, :boolean) 15 | field(:self_deaf, :boolean) 16 | field(:self_mute, :boolean) 17 | field(:self_stream, :boolean) 18 | field(:self_video, :boolean) 19 | field(:suppress, :boolean) 20 | field(:request_to_speak_timestamp, :utc_datetime) 21 | 22 | field(:member, :any, virtual: true) 23 | 24 | belongs_to(:user, User, primary_key: true) 25 | belongs_to(:guild, Guild, primary_key: true) 26 | belongs_to(:channel, Channel) 27 | end 28 | 29 | def preload(%VoiceState{member: %Member{}} = voice_state, :member, options) do 30 | if options[:force] do 31 | voice_state = %{voice_state | member: nil} 32 | preload(voice_state, :member, options) 33 | else 34 | voice_state 35 | end 36 | end 37 | 38 | def preload(%VoiceState{user_id: user_id, guild_id: guild_id} = voice_state, :member, options) do 39 | member = Member.get({user_id, guild_id}, options) 40 | %{voice_state | member: member} 41 | end 42 | 43 | def preload(voice_state, association, options) do 44 | super(voice_state, association, options) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /guides/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | This guide offers a quick and simple example to get started with the library. 4 | 5 | ### Installation 6 | 7 | Add coxir as a dependency to your `mix.exs` file: 8 | 9 | ```elixir 10 | defp deps do 11 | [{:coxir, git: "https://github.com/satom99/coxir.git"}] 12 | end 13 | ``` 14 | 15 | ### Consuming events 16 | 17 | In order to start consuming events from the Discord gateway, first configure the library: 18 | 19 | ```elixir 20 | config :coxir, 21 | token: "", 22 | intents: :non_privileged 23 | ``` 24 | 25 | And then define a module that will be responsible for handling the incoming events like: 26 | 27 | ```elixir 28 | defmodule Example.Bot do 29 | use Coxir.Gateway 30 | 31 | alias Coxir.{User, Message} 32 | 33 | def handle_event({:MESSAGE_CREATE, %Message{content: "!hello"} = message}) do 34 | %Message{author: author} = Message.preload(message, :author) 35 | 36 | %User{username: username, discriminator: discriminator} = author 37 | 38 | Message.reply(message, content: "Hello #{username}##{discriminator}!") 39 | end 40 | 41 | def handle_event(_event) do 42 | :noop 43 | end 44 | end 45 | ``` 46 | 47 | Which can then be added to a Supervisor as a child, or started directly from `iex` like: 48 | 49 | ```elixir 50 | iex(1)> Example.Bot.start_link() 51 | {:ok, #PID<0.301.0>} 52 | ``` 53 | 54 | For a complete and working example it is highly recommended to check out the [`example`](https://github.com/satom99/coxir/tree/main/example) app. 55 | -------------------------------------------------------------------------------- /lib/coxir/gateway/intents.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Intents do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | import Bitwise 6 | 7 | @values [ 8 | guilds: 1 <<< 0, 9 | guild_members: 1 <<< 1, 10 | guild_bans: 1 <<< 2, 11 | guild_emojis: 1 <<< 3, 12 | guild_integrations: 1 <<< 4, 13 | guild_webhooks: 1 <<< 5, 14 | guild_invites: 1 <<< 6, 15 | guild_voice_states: 1 <<< 7, 16 | guild_presences: 1 <<< 8, 17 | guild_messages: 1 <<< 9, 18 | guild_message_reactions: 1 <<< 10, 19 | guild_message_typing: 1 <<< 11, 20 | direct_messages: 1 <<< 12, 21 | direct_message_reactions: 1 <<< 13, 22 | direct_message_typing: 1 <<< 14 23 | ] 24 | @intents Keyword.keys(@values) 25 | 26 | @privileged [:guild_members, :guild_presences] 27 | @non_privileged @intents -- @privileged 28 | 29 | @typespec @intents 30 | |> Enum.reverse() 31 | |> Enum.reduce(fn name, type -> 32 | {:|, [], [name, type]} 33 | end) 34 | 35 | @type intent :: unquote(@typespec) 36 | 37 | @type intents :: :all | :non_privileged | list(intent) 38 | 39 | @spec get_value(intents) :: non_neg_integer 40 | def get_value(:all) do 41 | get_value(@intents) 42 | end 43 | 44 | def get_value(:non_privileged) do 45 | get_value(@non_privileged) 46 | end 47 | 48 | def get_value(intents) when is_list(intents) do 49 | Enum.reduce( 50 | intents, 51 | 0, 52 | fn intent, value -> 53 | Keyword.fetch!(@values, intent) ||| value 54 | end 55 | ) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/coxir/gateway/payload.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Payload do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | alias __MODULE__ 6 | 7 | @operations %{ 8 | 0 => :DISPATCH, 9 | 1 => :HEARTBEAT, 10 | 2 => :IDENTIFY, 11 | 3 => :PRESENCE_UPDATE, 12 | 4 => :VOICE_STATE_UPDATE, 13 | 6 => :RESUME, 14 | 7 => :RECONNECT, 15 | 8 => :REQUEST_GUILD_MEMBERS, 16 | 9 => :INVALID_SESSION, 17 | 10 => :HELLO, 18 | 11 => :HEARTBEAT_ACK 19 | } 20 | @codes Map.new(@operations, fn {key, value} -> {value, key} end) 21 | 22 | defstruct [ 23 | :operation, 24 | :data, 25 | :sequence, 26 | :event, 27 | :gateway, 28 | :user_id 29 | ] 30 | 31 | @type t :: %Payload{} 32 | 33 | defmacro __using__(_options) do 34 | quote location: :keep do 35 | use Coxir.Model, storable?: false 36 | 37 | @primary_key false 38 | 39 | @derive Jason.Encoder 40 | 41 | @type t :: %__MODULE__{} 42 | 43 | def cast(object) do 44 | Coxir.Model.Loader.load(__MODULE__, object) 45 | end 46 | end 47 | end 48 | 49 | def cast(%{"op" => opcode, "d" => data, "s" => sequence, "t" => event}, user_id) do 50 | operation = Map.get(@operations, opcode, opcode) 51 | 52 | %Payload{ 53 | operation: operation, 54 | data: data, 55 | sequence: sequence, 56 | event: event, 57 | user_id: user_id 58 | } 59 | end 60 | 61 | def to_command(%Payload{operation: operation, data: data}) do 62 | opcode = Map.fetch!(@codes, operation) 63 | %{"op" => opcode, "d" => data} 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/ban.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Ban do 2 | @moduledoc """ 3 | Represents a Discord guild ban. 4 | """ 5 | use Coxir.Model, storable?: false 6 | 7 | @primary_key false 8 | 9 | @typedoc """ 10 | The struct for a ban. 11 | """ 12 | @type t :: %Ban{ 13 | user: user, 14 | user_id: user_id, 15 | guild: guild, 16 | guild_id: guild_id 17 | } 18 | 19 | @typedoc """ 20 | The coxir key of a ban. 21 | """ 22 | @type key :: {user_id, guild_id} 23 | 24 | @typedoc """ 25 | The id of the banned user. 26 | """ 27 | @type user_id :: Snowflake.t() 28 | 29 | @typedoc """ 30 | The banned user. 31 | 32 | Needs to be preloaded via `preload/3`. 33 | """ 34 | @type user :: NotLoaded.t() | User.t() | Error.t() 35 | 36 | @typedoc """ 37 | The id of the guild the ban belongs to. 38 | """ 39 | @type guild_id :: Snowflake.t() 40 | 41 | @typedoc """ 42 | The guild the ban belongs to. 43 | 44 | Needs to be preloaded via `preload/3`. 45 | """ 46 | @type guild :: NotLoaded.t() | Guild.t() | Error.t() 47 | 48 | embedded_schema do 49 | belongs_to(:user, User, primary_key: true) 50 | belongs_to(:guild, Guild, primary_key: true) 51 | end 52 | 53 | def fetch({user_id, guild_id}, options) do 54 | API.get("guilds/#{guild_id}/bans/#{user_id}", options) 55 | end 56 | 57 | def insert(%{user_id: user_id, guild_id: guild_id} = params, options) do 58 | API.put("guilds/#{guild_id}/bans/#{user_id}", params, options) 59 | end 60 | 61 | def drop({user_id, guild_id}, options) do 62 | API.delete("guilds/#{guild_id}/bans/#{user_id}", options) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /guides/entities.md: -------------------------------------------------------------------------------- 1 | # Entities 2 | 3 | This guide shows the correct use of the functions of an entity. 4 | 5 | ### Errors 6 | 7 | Suppose we want to get a specific Discord channel. We would do as follows: 8 | 9 | ```elixir 10 | Coxir.Channel.get(432535429162729494) 11 | ``` 12 | 13 | Which would return a `t:Coxir.Channel.t/0` struct if everything goes right. However: 14 | 15 | ```elixir 16 | Coxir.Channel.get(0) 17 | ``` 18 | 19 | Here we are trying to get a channel with an invalid id. This is obviously going to fail. 20 | 21 | In this case we instead get a `t:Coxir.API.Error.t/0` struct describing what went wrong. 22 | 23 | Now imagine we try to get a channel we don't have permissions to view. We get another error. 24 | 25 | So when getting entities, always make sure the returned struct is not that of an error. 26 | 27 | ### Bang functions 28 | 29 | If you however want to keep it simple, the bang equivalent function can be used: 30 | 31 | ```elixir 32 | Coxir.Channel.get!(0) 33 | ``` 34 | 35 | Which will just raise if there is an error. These should be used with caution, however. 36 | 37 | ### Preloading 38 | 39 | Suppose we want to get the guild a specific channel belongs to. We can do as follows: 40 | 41 | ```elixir 42 | channel = Coxir.Channel.preload!(channel, :guild) 43 | ``` 44 | 45 | Which will set the `:guild` field of the given `channel` to the associated `t:Coxir.Guild.t/0`. 46 | 47 | If the given channel has no associated guild, the field will simply be set to `nil`. 48 | 49 | If there is an error getting the associated guild, the field will be set to the error's struct. 50 | 51 | Note though that the example uses `preload!/2` which means that it will raise in case of error. 52 | -------------------------------------------------------------------------------- /lib/coxir/adapters/player.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Player do 2 | @moduledoc """ 3 | Handles the audio sent through voice. 4 | """ 5 | alias Coxir.Voice.Audio 6 | 7 | @typedoc """ 8 | A module that implements the behaviour. 9 | """ 10 | @type t :: module 11 | 12 | @typedoc """ 13 | A player process. 14 | """ 15 | @type player :: pid 16 | 17 | @typedoc """ 18 | Specifies what can be played with the `t:t/0` in use. 19 | """ 20 | @type playable :: term 21 | 22 | @typedoc """ 23 | Specifies the options that can be passed to the `t:t/0` in use. 24 | """ 25 | @type options :: keyword 26 | 27 | @typedoc """ 28 | The argument that is passed to `c:child_spec/1`. 29 | """ 30 | @type init_argument :: {playable, options} 31 | 32 | @doc """ 33 | Must return a child specification from a `t:init_argument/0`. 34 | """ 35 | @callback child_spec(init_argument) :: Supervisor.child_spec() 36 | 37 | @doc """ 38 | Called when the connection to the Discord voice channel is ready. 39 | 40 | The received `t:Coxir.Voice.Audio.t/0` struct can be used to send audio. 41 | """ 42 | @callback ready(player, Audio.t()) :: :ok 43 | 44 | @doc """ 45 | Called when the connection to the Discord voice channel is lost. 46 | 47 | This invalidates the previously received `t:Coxir.Voice.Audio.t/0` struct. 48 | 49 | Thus the player should stop sending audio until `c:ready/2` is called again. 50 | """ 51 | @callback invalidate(player) :: :ok 52 | 53 | @doc """ 54 | Called to pause audio playback. 55 | """ 56 | @callback pause(player) :: :ok 57 | 58 | @doc """ 59 | Called to resume audio playback. 60 | """ 61 | @callback resume(player) :: :ok 62 | 63 | @doc """ 64 | Called to check whether audio playback is paused. 65 | """ 66 | @callback playing?(player) :: boolean 67 | end 68 | -------------------------------------------------------------------------------- /guides/multiple-clients.md: -------------------------------------------------------------------------------- 1 | # Multiple clients 2 | 3 | This guide explains how multiple clients can be run at once. 4 | 5 | ### Configuration 6 | 7 | Each client's `token` must be configured per-gateway as shown in the Configuration guide. 8 | 9 | ### Multiple gateways 10 | 11 | First of all, let us define two separate modules using the `Coxir.Gateway` module as follows: 12 | 13 | ```elixir 14 | defmodule Example.Adam do 15 | use Coxir.Gateway 16 | 17 | def handle_event(_event) do 18 | :noop 19 | end 20 | end 21 | 22 | defmodule Example.Eva do 23 | use Coxir.Gateway 24 | 25 | def handle_event(_event) do 26 | :noop 27 | end 28 | end 29 | ``` 30 | 31 | For which we can then, as mentioned, configure their `token` separately the following way: 32 | 33 | ```elixir 34 | config :coxir, Example.Adam, token: "" 35 | 36 | config :coxir, Example.Eva, token: "" 37 | ``` 38 | 39 | Then as mentioned in the Configuration guide, when calling functions we must pass the gateway along: 40 | 41 | ```elixir 42 | defmodule Example.Adam do 43 | use Coxir.Gateway 44 | 45 | alias Coxir.Message 46 | alias __MODULE__ 47 | 48 | def handle_event({:MESSAGE_CREATE, %Message{content: "!hello"} = message}) do 49 | Message.reply(message, content: "Hello this is Adam!", as: Adam) 50 | end 51 | 52 | def handle_event(_event) do 53 | :noop 54 | end 55 | end 56 | 57 | defmodule Example.Eva do 58 | use Coxir.Gateway 59 | 60 | alias Coxir.Message 61 | alias __MODULE__ 62 | 63 | def handle_event({:MESSAGE_CREATE, %Message{content: "!hello"} = message}) do 64 | Message.reply(message, content: "Hello this is Eva!", as: Eva) 65 | end 66 | 67 | def handle_event(_event) do 68 | :noop 69 | end 70 | end 71 | ``` 72 | 73 | In which case the resulting behaviour will be as expected from two independent clients. 74 | -------------------------------------------------------------------------------- /guides/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | This guide explains the different configuration possibilities the library has. 4 | 5 | ### Global 6 | 7 | The following table shows a complete list of the fields that can be configured globally: 8 | 9 | | Field | Description | Default | Global? | 10 | |---------------|------------------------------------------|--------------------------------|---------| 11 | | `limiter` | The Limiter adapter coxir should use. | `Coxir.Limiter.Default` | ✓ | 12 | | `storage` | The Storage adapter coxir should use. | `Coxir.Storage.Default` | ✓ | 13 | | `storable` | The list of entities coxir should store. | All storable entities. | ✓ | 14 | | `token` | The token to be used for API calls. | | | 15 | | `ìntents` | The gateway intents value to use. | `:non_privileged` | | 16 | | `shard_count` | The amount of gateway shards to use. | The value provided by Discord. | | 17 | 18 | Which means that either of these fields can be configured under the `:coxir` application. 19 | 20 | ### Gateway-specific 21 | 22 | Instead of global configuration, fields not marked as **GLOBAL?** can be configured at gateway level like: 23 | 24 | ```elixir 25 | config :coxir, Example.Bot, token: "" 26 | ``` 27 | 28 | where `Example.Bot` in this example is the name of a module that uses the `Coxir.Gateway` module. 29 | 30 | ### When a token is not configured globally 31 | 32 | If no token is configured globally but configured for a gateway instead, the following is required: 33 | 34 | ```elixir 35 | Coxir.Channel.get(432535429162729494, as: Example.Bot) 36 | ``` 37 | 38 | where a gateway process must be passed as the `:as` option. Or if there is no available gateway: 39 | 40 | ```elixir 41 | Coxir.Channel.get(432535429162729494, token: "") 42 | ``` 43 | 44 | Note that this applies to most functions. Be sure to always check out the functions' specification. 45 | -------------------------------------------------------------------------------- /lib/coxir/adapters/limiter/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Limiter.Default do 2 | @moduledoc """ 3 | Stores bucket information in ETS. 4 | """ 5 | use Coxir.Limiter 6 | use GenServer 7 | 8 | @table __MODULE__ 9 | 10 | def start_link(state) do 11 | GenServer.start_link(__MODULE__, state, name: __MODULE__) 12 | end 13 | 14 | def init(state) do 15 | :ets.new(@table, [:named_table, :public]) 16 | {:ok, state} 17 | end 18 | 19 | def put(bucket, limit, reset) do 20 | matcher = [ 21 | { 22 | {:"$1", :"$2", :"$3"}, 23 | [ 24 | { 25 | :andalso, 26 | {:==, :"$1", bucket}, 27 | { 28 | :orelse, 29 | {:>, :"$2", limit}, 30 | {:<, :"$3", reset} 31 | } 32 | } 33 | ], 34 | [ 35 | { 36 | {:"$1", limit, reset} 37 | } 38 | ] 39 | } 40 | ] 41 | 42 | if not :ets.insert_new(@table, [{bucket, limit, reset}]) do 43 | :ets.select_replace(@table, matcher) 44 | end 45 | 46 | :ok 47 | end 48 | 49 | def hit(bucket) do 50 | matcher = [ 51 | { 52 | {:"$1", :"$2", :"$3"}, 53 | [ 54 | { 55 | :andalso, 56 | {:==, :"$1", bucket}, 57 | {:>, :"$2", 0} 58 | } 59 | ], 60 | [ 61 | { 62 | {:"$1", {:-, :"$2", 1}, :"$3"} 63 | } 64 | ] 65 | }, 66 | { 67 | {:"$1", :"$2", :"$3"}, 68 | [ 69 | { 70 | :andalso, 71 | {:==, :"$1", bucket}, 72 | {:<, {:-, :"$3", time_now()}, 0} 73 | } 74 | ], 75 | [ 76 | { 77 | {:"$1", :"$2", offset_now(1000)} 78 | } 79 | ] 80 | } 81 | ] 82 | 83 | if :ets.select_replace(@table, matcher) > 0 do 84 | :ok 85 | else 86 | case :ets.lookup(@table, bucket) do 87 | [{^bucket, _limit, reset}] -> 88 | timeout = max(reset - time_now(), 0) 89 | {:error, timeout} 90 | 91 | _none -> 92 | :ok 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/coxir/adapters/storage.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Storage do 2 | @moduledoc """ 3 | Handles how models are cached. 4 | 5 | ### Configuration 6 | 7 | A list of models to be stored can be configured as `:storable` under the `:coxir` app. 8 | 9 | If no such list is configured, all models hardcoded as storable will be stored by default. 10 | 11 | A custom storage adapter can be configured as `:adapter` under the `:coxir` app. 12 | 13 | If no custom adapter is configured, the default `Coxir.Storage.Default` will be used. 14 | """ 15 | alias Coxir.Model 16 | 17 | @callback child_spec(term) :: Supervisor.child_spec() 18 | 19 | @callback put(Model.instance()) :: Model.instance() 20 | 21 | @callback all(Model.model()) :: list(Model.instance()) 22 | 23 | @callback all_by(Model.model(), keyword) :: list(Model.instance()) 24 | 25 | @callback get(Model.model(), Model.key()) :: Model.instance() | nil 26 | 27 | @callback get_by(Model.model(), keyword) :: Model.instance() | nil 28 | 29 | @callback delete(Model.model(), Model.key()) :: :ok 30 | 31 | @callback delete_by(Model.model(), keyword) :: :ok 32 | 33 | defmacro __using__(_options) do 34 | quote location: :keep do 35 | @behaviour Coxir.Storage 36 | 37 | import Coxir.Model.Helper 38 | end 39 | end 40 | 41 | @doc false 42 | def child_spec(term) do 43 | storage().child_spec(term) 44 | end 45 | 46 | @doc false 47 | def put(struct) do 48 | storage().put(struct) 49 | end 50 | 51 | @doc false 52 | def all(model) do 53 | storage().all(model) 54 | end 55 | 56 | @doc false 57 | def all_by(model, clauses) do 58 | storage().all_by(model, clauses) 59 | end 60 | 61 | @doc false 62 | def get(model, key) do 63 | storage().get(model, key) 64 | end 65 | 66 | @doc false 67 | def get_by(model, clauses) do 68 | storage().get_by(model, clauses) 69 | end 70 | 71 | @doc false 72 | def delete(model, key) do 73 | storage().delete(model, key) 74 | end 75 | 76 | @doc false 77 | def delete_by(model, clauses) do 78 | storage().delete_by(model, clauses) 79 | end 80 | 81 | defp storage do 82 | Application.get_env(:coxir, :storage, Coxir.Storage.Default) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/coxir/model/helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Model.Helper do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | import Ecto.Changeset 6 | 7 | alias Coxir.Model 8 | 9 | @spec merge(Model.instance(), Model.instance()) :: Model.instance() 10 | def merge(%model{} = base, %model{} = overwrite) do 11 | fields = get_fields(model) 12 | embeds = get_embeds(model) 13 | assocs = get_associations(model) 14 | params = Map.take(overwrite, fields) 15 | 16 | keep = Map.take(overwrite, embeds ++ assocs) 17 | 18 | base 19 | |> cast(params, fields -- embeds) 20 | |> apply_changes() 21 | |> Map.merge(keep) 22 | end 23 | 24 | @spec storable?(Model.model()) :: boolean 25 | def storable?(model) do 26 | with true <- model.storable?() do 27 | if storable = Application.get_env(:coxir, :storable) do 28 | model in storable 29 | else 30 | true 31 | end 32 | end 33 | end 34 | 35 | @spec get_key(Model.instance()) :: Model.key() 36 | def get_key(%model{} = struct) do 37 | primary = get_primary(model) 38 | 39 | case take_fields(struct, primary) do 40 | [single] -> single 41 | multiple -> List.to_tuple(multiple) 42 | end 43 | end 44 | 45 | @spec get_primary(Model.model()) :: list(atom) 46 | def get_primary(model) do 47 | model.__schema__(:primary_key) 48 | end 49 | 50 | @spec get_fields(Model.model()) :: list(atom) 51 | def get_fields(model) do 52 | model.__schema__(:fields) 53 | end 54 | 55 | @spec get_embeds(Model.model()) :: list(atom) 56 | def get_embeds(model) do 57 | model.__schema__(:embeds) 58 | end 59 | 60 | @spec get_associations(Model.model()) :: list(atom) 61 | def get_associations(model) do 62 | model.__schema__(:associations) 63 | end 64 | 65 | @spec get_association(Model.model(), atom) :: struct 66 | def get_association(model, name) do 67 | model.__schema__(:association, name) 68 | end 69 | 70 | @spec get_values(Model.instance()) :: list 71 | def get_values(%model{} = struct) do 72 | fields = get_fields(model) 73 | take_fields(struct, fields) 74 | end 75 | 76 | defp take_fields(struct, fields) do 77 | Enum.map( 78 | fields, 79 | fn name -> 80 | Map.fetch!(struct, name) 81 | end 82 | ) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # coxir 2 | 3 | [![License](https://img.shields.io/github/license/satom99/coxir.svg)](https://github.com/satom99/coxir/blob/main/LICENSE) 4 | [![Validation](https://github.com/satom99/coxir/actions/workflows/validation.yml/badge.svg)](https://github.com/satom99/coxir/actions/workflows/validation.yml) 5 | [![Documentation](https://github.com/satom99/coxir/actions/workflows/documentation.yml/badge.svg)](https://github.com/satom99/coxir/actions/workflows/documentation.yml) 6 | [![Join Discord](https://img.shields.io/badge/Discord-join-5865F2.svg)](https://discord.gg/6JrqNEX) 7 | 8 | A modern high-level Elixir wrapper for [Discord](https://discord.com). 9 | 10 | Refer to the [documentation](https://satom.me/coxir) for more information. 11 | 12 | ### Features 13 | 14 | - Support for running multiple bots in a same application 15 | - Configurable adapters that change how the library behaves: 16 | - **Limiter:** handles how rate limit buckets are stored 17 | - **Storage:** handles how entities are cached 18 | - **Sharder:** handles how shards are started 19 | - **Player:** handles the audio sent through voice 20 | - Easy-to-use syntax for interacting with Discord entities 21 | 22 | ### Installation 23 | 24 | Add coxir as a dependency to your `mix.exs` file: 25 | 26 | ```elixir 27 | defp deps do 28 | [{:coxir, git: "https://github.com/satom99/coxir.git"}] 29 | end 30 | ``` 31 | 32 | ### Quickstart 33 | 34 | Before consuming events, coxir must be configured: 35 | 36 | ```elixir 37 | config :coxir, 38 | token: "", 39 | intents: :non_privileged # optional 40 | ``` 41 | 42 | Then a simple consumer can be set up as follows: 43 | 44 | ```elixir 45 | defmodule Example.Bot do 46 | use Coxir.Gateway 47 | 48 | alias Coxir.{User, Message} 49 | 50 | def handle_event({:MESSAGE_CREATE, %Message{content: "!hello"} = message}) do 51 | %Message{author: author} = Message.preload(message, :author) 52 | 53 | %User{username: username, discriminator: discriminator} = author 54 | 55 | Message.reply(message, content: "Hello #{username}##{discriminator}!") 56 | end 57 | 58 | def handle_event(_event) do 59 | :noop 60 | end 61 | end 62 | ``` 63 | 64 | Which can then be added to a Supervisor, or started directly: 65 | 66 | ```elixir 67 | iex(1)> Example.Bot.start_link() 68 | {:ok, #PID<0.301.0>} 69 | ``` 70 | 71 | For a complete and working example check out the [`example`](https://github.com/satom99/coxir/tree/main/example) app. 72 | 73 | ### More 74 | 75 | For more information check out the [documentation guides](https://satom.me/coxir). 76 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Coxir.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :coxir, 7 | version: "2.0.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | docs: docs(), 12 | package: package(), 13 | source_url: "https://github.com/satom99/coxir" 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | mod: {Coxir, []} 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:ecto, ">= 3.0.0"}, 26 | {:jason, ">= 1.0.0"}, 27 | {:idna, ">= 6.0.0"}, 28 | {:castore, ">= 0.1.0"}, 29 | {:gun, ">= 1.3.0"}, 30 | {:tesla, ">= 1.3.0"}, 31 | {:gen_stage, ">= 0.14.0"}, 32 | {:kcl, ">= 1.0.0"}, 33 | {:porcelain, ">= 2.0.3"}, 34 | {:ex_doc, "~> 0.24.2", only: :dev} 35 | ] 36 | end 37 | 38 | defp docs do 39 | [ 40 | groups_for_modules: [ 41 | Entities: [ 42 | Coxir.User, 43 | Coxir.Channel, 44 | Coxir.Guild, 45 | Coxir.Invite, 46 | Coxir.Overwrite, 47 | Coxir.Webhook, 48 | ~r/^Coxir.Message.?/, 49 | Coxir.Emoji, 50 | Coxir.Reaction, 51 | ~r/^Coxir.Interaction.?/, 52 | Coxir.Integration, 53 | Coxir.Role, 54 | Coxir.Ban, 55 | Coxir.Member, 56 | ~r/^Coxir.Presence.?/, 57 | Coxir.VoiceState 58 | ], 59 | Gateway: [ 60 | ~r/^Coxir.Gateway.?/, 61 | ~r/^Coxir.Payload.?/ 62 | ], 63 | Voice: [ 64 | ~r/^Coxir.Voice.?/ 65 | ], 66 | Adapters: [ 67 | ~r/^Coxir.Limiter.?/, 68 | ~r/^Coxir.Storage.?/, 69 | ~r/^Coxir.Sharder.?/, 70 | ~r/^Coxir.Player.?/ 71 | ], 72 | Model: [ 73 | ~r/^Coxir.Model.?/ 74 | ], 75 | API: [ 76 | ~r/^Coxir.API.?/ 77 | ], 78 | Other: ~r/(.*?)/ 79 | ], 80 | extra_section: "GUIDES", 81 | extras: [ 82 | "guides/introduction.md", 83 | "guides/quickstart.md", 84 | "guides/configuration.md", 85 | "guides/multiple-clients.md", 86 | "guides/entities.md" 87 | ], 88 | main: "introduction", 89 | source_ref: "main" 90 | ] 91 | end 92 | 93 | defp package do 94 | [ 95 | licenses: ["Apache-2.0"], 96 | links: %{"GitHub" => "https://github.com/satom99/coxir"} 97 | ] 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Message do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | alias Coxir.Message.{Attachment, Embed, Reference, Component} 8 | 9 | @type t :: %Message{} 10 | 11 | embedded_schema do 12 | field(:content, :string) 13 | field(:timestamp, :utc_datetime) 14 | field(:edited_timestamp, :utc_datetime) 15 | field(:tts, :boolean) 16 | field(:mention_everyone, :boolean) 17 | field(:mention_roles, {:array, Snowflake}) 18 | field(:nonce, :string) 19 | field(:pinned, :boolean) 20 | field(:type, :integer) 21 | field(:flags, :integer) 22 | 23 | embeds_many(:attachments, Attachment) 24 | embeds_many(:embeds, Embed) 25 | 26 | embeds_one(:message_reference, Reference) 27 | embeds_many(:components, Component) 28 | 29 | belongs_to(:channel, Channel, primary_key: true) 30 | belongs_to(:guild, Guild) 31 | belongs_to(:author, User) 32 | belongs_to(:referenced_message, Message) 33 | end 34 | 35 | def fetch({id, channel_id}, options) do 36 | API.get("channels/#{channel_id}/messages/#{id}", options) 37 | end 38 | 39 | def insert(%{channel_id: channel_id} = params, options) do 40 | API.post("channels/#{channel_id}/messages", params, options) 41 | end 42 | 43 | def patch({id, channel_id}, params, options) do 44 | API.patch("channels/#{channel_id}/messages/#{id}", params, options) 45 | end 46 | 47 | def drop({id, channel_id}, options) do 48 | API.delete("channels/#{channel_id}/messages/#{id}", options) 49 | end 50 | 51 | @spec reply(t, Enum.t(), Loader.options()) :: Loader.result() 52 | def reply(%Message{id: id, channel_id: channel_id}, params, options \\ []) do 53 | reference = %{message_id: id} 54 | 55 | params 56 | |> Map.new() 57 | |> Map.put(:channel_id, channel_id) 58 | |> Map.put(:message_reference, reference) 59 | |> create(options) 60 | end 61 | 62 | @spec pin(t, Loader.options()) :: Loader.result() 63 | def pin(%Message{id: id, channel_id: channel_id}, options \\ []) do 64 | API.put("channels/#{channel_id}/pins/#{id}", options) 65 | end 66 | 67 | @spec unpin(t, Loader.options()) :: Loader.result() 68 | def unpin(%Message{id: id, channel_id: channel_id}, options \\ []) do 69 | API.delete("channels/#{channel_id}/pins/#{id}", options) 70 | end 71 | 72 | @spec react(t, Emoji.t() | String.t(), Loader.options()) :: Loader.result() 73 | def react(%Message{id: id, channel_id: channel_id}, emoji, options \\ []) do 74 | params = %{message_id: id, channel_id: channel_id, emoji: emoji} 75 | Reaction.create(params, options) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/coxir/api.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.API do 2 | @moduledoc """ 3 | Entry-point to the Discord REST API. 4 | """ 5 | use Tesla, only: [], docs: false 6 | 7 | alias Tesla.Env 8 | alias Coxir.{Gateway, Token} 9 | alias Coxir.API.Error 10 | 11 | @typedoc """ 12 | The options that can be passed to `perform/4`. 13 | 14 | If the `:as` option is present, the token of the given gateway will be used. 15 | 16 | If no token is provided, one is expected to be configured as `:token` under the `:coxir` app. 17 | """ 18 | @type options :: [ 19 | as: Gateway.gateway() | none, 20 | token: Token.t() | none 21 | ] 22 | 23 | @typedoc """ 24 | The possible outcomes of `perform/4`. 25 | """ 26 | @type result :: :ok | {:ok, map} | {:ok, list(map)} | {:error, Error.t()} 27 | 28 | adapter(Tesla.Adapter.Gun) 29 | 30 | plug(Coxir.API.Headers) 31 | 32 | plug(Tesla.Middleware.BaseUrl, "https://discord.com/api/v9") 33 | 34 | plug(Tesla.Middleware.JSON) 35 | 36 | plug(Tesla.Middleware.Retry) 37 | 38 | plug(Coxir.API.RateLimiter) 39 | 40 | @doc """ 41 | Performs a request to the API. 42 | """ 43 | @spec perform(Env.method(), Env.url(), Env.query(), Env.body(), options) :: result 44 | def perform(method, path, query, body, options) do 45 | case request!(method: method, url: path, query: query, body: body, opts: options) do 46 | %{status: 204} -> 47 | :ok 48 | 49 | %{status: status, body: body} when status in [200, 201, 304] -> 50 | {:ok, body} 51 | 52 | %{status: status, body: body} -> 53 | error = Error.cast(status, body) 54 | {:error, error} 55 | end 56 | end 57 | 58 | @doc """ 59 | Delegates to `perform/4` with `method` set to `:get`. 60 | """ 61 | @spec get(Env.url(), Env.query(), options) :: result 62 | def get(path, query \\ [], options) do 63 | perform(:get, path, query, nil, options) 64 | end 65 | 66 | @doc """ 67 | Delegates to `perform/4` with `method` set to `:post`. 68 | """ 69 | @spec post(Env.url(), Env.body(), options) :: result 70 | def post(path, body \\ %{}, options) do 71 | perform(:post, path, [], body, options) 72 | end 73 | 74 | @doc """ 75 | Delegates to `perform/4` with `method` set to `:put`. 76 | """ 77 | @spec put(Env.url(), Env.body(), options) :: result 78 | def put(path, body \\ %{}, options) do 79 | perform(:put, path, [], body, options) 80 | end 81 | 82 | @doc """ 83 | Delegates to `perform/4` with `method` set to `:patch`. 84 | """ 85 | @spec patch(Env.url(), Env.body(), options) :: result 86 | def patch(path, body, options) do 87 | perform(:patch, path, [], body, options) 88 | end 89 | 90 | @doc """ 91 | Delegates to `perform/4` with `method` set to `:delete`. 92 | """ 93 | @spec delete(Env.url(), options) :: result 94 | def delete(path, options) do 95 | perform(:delete, path, [], nil, options) 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/coxir/api/ratelimiter.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.API.RateLimiter do 2 | @moduledoc """ 3 | Responsible for handling ratelimits. 4 | """ 5 | @behaviour Tesla.Middleware 6 | 7 | import Coxir.Limiter.Helper 8 | import Coxir.API.Helper 9 | 10 | alias Coxir.{Limiter, Token} 11 | 12 | @major_params ["guilds", "channels", "webhooks"] 13 | @regex ~r|/?([\w-]+)/(?:\d+)|i 14 | 15 | @header_remaining "x-ratelimit-remaining" 16 | @header_reset "x-ratelimit-reset" 17 | @header_global "x-ratelimit-global" 18 | @header_retry "retry-after" 19 | @header_date "date" 20 | 21 | def call(request, next, options) do 22 | bucket = get_bucket(request) 23 | 24 | :ok = wait_hit(:global) 25 | :ok = wait_hit(bucket) 26 | 27 | with {:ok, %{status: status} = response} <- Tesla.run(request, next) do 28 | update_bucket(bucket, response) 29 | 30 | if status == 429 do 31 | call(request, next, options) 32 | else 33 | {:ok, response} 34 | end 35 | end 36 | end 37 | 38 | defp update_bucket(bucket, response) do 39 | global = Tesla.get_header(response, @header_global) 40 | remaining = Tesla.get_header(response, @header_remaining) 41 | reset = Tesla.get_header(response, @header_reset) 42 | retry = Tesla.get_header(response, @header_retry) 43 | date = Tesla.get_header(response, @header_date) 44 | 45 | remaining = if remaining, do: String.to_integer(remaining) 46 | 47 | reset = if reset, do: string_to_float(reset) * 1000 48 | retry = if retry, do: string_to_float(retry) * 1000 49 | 50 | reset = if reset, do: round(reset) 51 | retry = if retry, do: round(retry) 52 | 53 | if reset || retry do 54 | bucket = if global, do: :global, else: bucket 55 | 56 | remaining = if remaining, do: remaining, else: 0 57 | 58 | reset = if reset, do: reset, else: time_now() + retry 59 | 60 | remote = unix_from_date(date) 61 | latency = abs(time_now() - remote) 62 | 63 | Limiter.put(bucket, remaining, reset + latency) 64 | end 65 | end 66 | 67 | defp string_to_float(string) do 68 | {float, _rest} = Float.parse(string) 69 | float 70 | end 71 | 72 | defp unix_from_date(header) do 73 | header 74 | |> String.to_charlist() 75 | |> :httpd_util.convert_request_date() 76 | |> :calendar.datetime_to_gregorian_seconds() 77 | |> :erlang.-(62_167_219_200) 78 | |> :erlang.*(1000) 79 | end 80 | 81 | defp get_bucket(%{method: method, url: url} = request) do 82 | user_id = 83 | request 84 | |> get_token() 85 | |> Token.get_user_id() 86 | 87 | bucket = 88 | case Regex.run(@regex, url) do 89 | [route, param] when param in @major_params -> 90 | if method == :delete and String.contains?(url, "messages") do 91 | "delete:" <> route 92 | else 93 | route 94 | end 95 | 96 | _other -> 97 | url 98 | end 99 | 100 | "#{user_id}:#{bucket}" 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/coxir/adapters/storage/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Storage.Default do 2 | @moduledoc """ 3 | Stores models in ETS. 4 | """ 5 | use Coxir.Storage 6 | use GenServer 7 | 8 | @server __MODULE__ 9 | @table __MODULE__ 10 | 11 | def start_link(state) do 12 | GenServer.start_link(__MODULE__, state, name: @server) 13 | end 14 | 15 | def init(state) do 16 | :ets.new(@table, [:named_table, :protected, {:read_concurrency, true}]) 17 | {:ok, state} 18 | end 19 | 20 | def handle_call({:create_table, model}, _from, state) do 21 | table = 22 | with nil <- lookup_table(model) do 23 | table = :ets.new(model, [:public]) 24 | :ets.insert(@table, {model, table}) 25 | table 26 | end 27 | 28 | {:reply, table, state} 29 | end 30 | 31 | def put(%model{} = struct) do 32 | table = get_table(model) 33 | key = get_key(struct) 34 | 35 | struct = 36 | case :ets.lookup(table, key) do 37 | [record] -> 38 | stored = from_record(model, record) 39 | merge(stored, struct) 40 | 41 | _none -> 42 | struct 43 | end 44 | 45 | record = to_record(struct) 46 | :ets.insert(table, record) 47 | 48 | struct 49 | end 50 | 51 | def all(model) do 52 | model 53 | |> get_table() 54 | |> :ets.tab2list() 55 | |> Enum.map(&from_record(model, &1)) 56 | end 57 | 58 | def all_by(model, clauses) do 59 | pattern = get_pattern(model, clauses) 60 | 61 | model 62 | |> get_table() 63 | |> :ets.match_object(pattern) 64 | |> Enum.map(&from_record(model, &1)) 65 | end 66 | 67 | def get(model, key) do 68 | record = 69 | model 70 | |> get_table() 71 | |> :ets.lookup(key) 72 | |> List.first() 73 | 74 | if record do 75 | from_record(model, record) 76 | end 77 | end 78 | 79 | def get_by(model, clauses) do 80 | table = get_table(model) 81 | pattern = get_pattern(model, clauses) 82 | 83 | case :ets.match_object(table, pattern, 1) do 84 | {[record], _continuation} -> 85 | from_record(model, record) 86 | 87 | _other -> 88 | nil 89 | end 90 | end 91 | 92 | def delete(model, key) do 93 | model 94 | |> get_table() 95 | |> :ets.delete(key) 96 | 97 | :ok 98 | end 99 | 100 | def delete_by(model, clauses) do 101 | pattern = get_pattern(model, clauses) 102 | 103 | model 104 | |> get_table() 105 | |> :ets.match_delete(pattern) 106 | 107 | :ok 108 | end 109 | 110 | defp get_pattern(model, clauses) do 111 | fields = get_fields(model) 112 | 113 | pattern = 114 | Enum.map( 115 | fields, 116 | fn name -> 117 | Keyword.get(clauses, name, :_) 118 | end 119 | ) 120 | 121 | List.to_tuple([:_ | pattern]) 122 | end 123 | 124 | defp to_record(struct) do 125 | key = get_key(struct) 126 | values = get_values(struct) 127 | List.to_tuple([key | values]) 128 | end 129 | 130 | defp from_record(model, record) do 131 | [_key | values] = Tuple.to_list(record) 132 | fields = get_fields(model) 133 | params = Enum.zip(fields, values) 134 | struct(model, params) 135 | end 136 | 137 | defp get_table(model) do 138 | with nil <- lookup_table(model) do 139 | GenServer.call(@server, {:create_table, model}) 140 | end 141 | end 142 | 143 | defp lookup_table(model) do 144 | case :ets.lookup(@table, model) do 145 | [{_model, table}] -> 146 | table 147 | 148 | _none -> 149 | nil 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /lib/coxir/adapters/player/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Player.Default do 2 | @moduledoc """ 3 | Pipes audio from ffmpeg. 4 | 5 | A custom path for ffmpeg can be set by configuring `:ffmpeg` under the `:coxir` app. 6 | """ 7 | @behaviour Coxir.Player 8 | 9 | use GenServer 10 | 11 | alias Porcelain.Process, as: Proc 12 | alias Coxir.Voice.Audio 13 | alias __MODULE__ 14 | 15 | @type playable :: String.t() 16 | 17 | @type options :: keyword 18 | 19 | defstruct [ 20 | :audio, 21 | :process, 22 | :playback, 23 | {:paused?, false} 24 | ] 25 | 26 | def ready(player, audio) do 27 | GenServer.call(player, {:ready, audio}) 28 | end 29 | 30 | def invalidate(player) do 31 | GenServer.call(player, :invalidate) 32 | end 33 | 34 | def pause(player) do 35 | GenServer.call(player, :pause) 36 | end 37 | 38 | def resume(player) do 39 | GenServer.call(player, :resume) 40 | end 41 | 42 | def playing?(player) do 43 | GenServer.call(player, :playing?) 44 | end 45 | 46 | def start_link(start) do 47 | GenServer.start_link(__MODULE__, start) 48 | end 49 | 50 | def init({url, _options}) do 51 | ffmpeg = Application.get_env(:coxir, :ffmpeg, "ffmpeg") 52 | 53 | options = ~w( 54 | -i #{url} 55 | -ac 2 56 | -ar 48000 57 | -f s16le 58 | -acodec libopus 59 | -loglevel quiet 60 | pipe:1 61 | ) 62 | 63 | process = %Proc{} = Porcelain.spawn(ffmpeg, options, out: :stream) 64 | 65 | state = %Default{process: process} 66 | {:ok, state} 67 | end 68 | 69 | def handle_call({:ready, audio}, _from, state) do 70 | state = %{state | audio: audio} 71 | state = update_playback(state) 72 | {:reply, :ok, state} 73 | end 74 | 75 | def handle_call(:invalidate, _from, state) do 76 | state = %{state | audio: nil} 77 | state = update_playback(state) 78 | {:reply, :ok, state} 79 | end 80 | 81 | def handle_call(:pause, _from, state) do 82 | state = %{state | paused?: true} 83 | state = update_playback(state) 84 | {:reply, :ok, state} 85 | end 86 | 87 | def handle_call(:resume, _from, state) do 88 | state = %{state | paused?: false} 89 | state = update_playback(state) 90 | {:reply, :ok, state} 91 | end 92 | 93 | def handle_call(:playing?, _from, %Default{paused?: paused?} = state) do 94 | {:reply, not paused?, state} 95 | end 96 | 97 | def handle_info({ref, :ended}, %Default{audio: audio, playback: %Task{ref: ref}} = state) do 98 | Audio.stop_speaking(audio) 99 | {:stop, :normal, state} 100 | end 101 | 102 | def handle_info({ref, _reason}, %Default{audio: audio, playback: %Task{ref: ref}} = state) do 103 | Audio.stop_speaking(audio) 104 | state = %{state | playback: nil} 105 | state = update_playback(state) 106 | {:noreply, state} 107 | end 108 | 109 | def handle_info(_message, state) do 110 | {:noreply, state} 111 | end 112 | 113 | defp update_playback(%Default{playback: playback} = state) when not is_nil(playback) do 114 | Task.shutdown(playback) 115 | state = %{state | playback: nil} 116 | update_playback(state) 117 | end 118 | 119 | defp update_playback(%Default{audio: nil} = state) do 120 | state 121 | end 122 | 123 | defp update_playback(%Default{paused?: true} = state) do 124 | state 125 | end 126 | 127 | defp update_playback(%Default{audio: audio} = state) do 128 | starter = fn -> 129 | Audio.start_speaking(audio) 130 | playback_loop(state) 131 | end 132 | 133 | playback = Task.async(starter) 134 | 135 | %{state | playback: playback} 136 | end 137 | 138 | defp playback_loop(%Default{audio: audio, process: process} = state) do 139 | %Proc{out: source} = process 140 | 141 | {audio, ended?, sleep} = Audio.process_burst(audio, source) 142 | 143 | if not ended? do 144 | Process.sleep(sleep) 145 | state = %{state | audio: audio} 146 | playback_loop(state) 147 | else 148 | :ended 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/coxir/voice/audio.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Audio do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | import Coxir.Limiter.Helper, only: [time_now: 0] 6 | 7 | alias Coxir.Voice.Payload.Speaking 8 | alias Coxir.Voice.Session 9 | alias __MODULE__ 10 | 11 | @type t :: %Audio{} 12 | 13 | defstruct [ 14 | :session, 15 | :udp_socket, 16 | :ip, 17 | :port, 18 | :ssrc, 19 | :secret_key, 20 | {:rtp_sequence, 0}, 21 | {:rtp_timestamp, 0}, 22 | :burst_timestamp 23 | ] 24 | 25 | @encryption_mode "xsalsa20_poly1305" 26 | @frame_samples 960 27 | @frame_duration 20000 28 | @burst_frames 10 29 | @burst_wait @burst_frames * @frame_duration 30 | @silence List.duplicate(<<0xF8, 0xFF, 0xFE>>, 5) 31 | 32 | def encryption_mode do 33 | @encryption_mode 34 | end 35 | 36 | def get_udp_socket do 37 | options = [ 38 | :binary, 39 | {:active, false}, 40 | {:reuseaddr, true} 41 | ] 42 | 43 | {:ok, socket} = :gen_udp.open(0, options) 44 | 45 | socket 46 | end 47 | 48 | def discover_local(udp_socket, remote_ip, remote_port, ssrc) do 49 | remote_address = ip_to_address(remote_ip) 50 | 51 | padded_remote_ip = String.pad_trailing(remote_ip, 64, <<0>>) 52 | 53 | request = <<1::16, 70::16, ssrc::32>> <> padded_remote_ip <> <> 54 | 55 | :ok = :gen_udp.send(udp_socket, remote_address, remote_port, request) 56 | 57 | {:ok, received} = :gen_udp.recv(udp_socket, 74) 58 | 59 | {^remote_address, ^remote_port, response} = received 60 | 61 | <<2::16, 70::16, ^ssrc::32, local_ip::bitstring-size(512), local_port::16>> = response 62 | 63 | local_ip = String.trim(local_ip, <<0>>) 64 | 65 | {local_ip, local_port} 66 | end 67 | 68 | @spec start_speaking(t) :: :ok 69 | def start_speaking(audio) do 70 | set_speaking(audio, 1) 71 | end 72 | 73 | @spec stop_speaking(t) :: :ok 74 | def stop_speaking(audio) do 75 | send_frames(audio, @silence) 76 | set_speaking(audio, 0) 77 | end 78 | 79 | @spec process_burst(t, Enum.t()) :: {t, boolean, timeout} 80 | def process_burst(%Audio{burst_timestamp: burst_timestamp} = audio, source) do 81 | frames = Enum.take(source, @burst_frames) 82 | 83 | ended? = length(frames) < @burst_frames 84 | 85 | audio = send_frames(audio, frames) 86 | 87 | now_timestamp = time_now() 88 | 89 | audio = %{audio | burst_timestamp: now_timestamp} 90 | 91 | burst_timestamp = burst_timestamp || now_timestamp 92 | 93 | wait = @burst_wait - (now_timestamp - burst_timestamp) 94 | 95 | sleep = max(trunc(wait / 1000), 0) 96 | 97 | {audio, ended?, sleep} 98 | end 99 | 100 | defp set_speaking(%Audio{session: session, ssrc: ssrc}, bit) do 101 | speaking = %Speaking{speaking: bit, ssrc: ssrc} 102 | Session.set_speaking(session, speaking) 103 | end 104 | 105 | defp send_frames(%Audio{} = audio, frames) do 106 | Enum.reduce( 107 | frames, 108 | audio, 109 | fn frame, audio -> 110 | send_frame(audio, frame) 111 | end 112 | ) 113 | end 114 | 115 | defp send_frame( 116 | %Audio{ 117 | udp_socket: udp_socket, 118 | ip: ip, 119 | port: port, 120 | rtp_sequence: rtp_sequence, 121 | rtp_timestamp: rtp_timestamp 122 | } = audio, 123 | frame 124 | ) do 125 | address = ip_to_address(ip) 126 | 127 | encrypted = encrypt_frame(audio, frame) 128 | 129 | :gen_udp.send(udp_socket, address, port, encrypted) 130 | 131 | %{audio | rtp_sequence: rtp_sequence + 1, rtp_timestamp: rtp_timestamp + @frame_samples} 132 | end 133 | 134 | defp encrypt_frame(%Audio{secret_key: secret_key} = audio, frame) do 135 | header = rtp_header(audio) 136 | nonce = header <> <<0::96>> 137 | header <> Kcl.secretbox(frame, nonce, secret_key) 138 | end 139 | 140 | defp rtp_header(%Audio{ssrc: ssrc, rtp_sequence: rtp_sequence, rtp_timestamp: rtp_timestamp}) do 141 | << 142 | 0x80::8, 143 | 0x78::8, 144 | rtp_sequence::16, 145 | rtp_timestamp::32, 146 | ssrc::32 147 | >> 148 | end 149 | 150 | defp ip_to_address(ip) do 151 | {:ok, address} = 152 | ip 153 | |> String.to_charlist() 154 | |> :inet_parse.address() 155 | 156 | address 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/member.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Member do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | @primary_key false 8 | 9 | @type t :: %Member{} 10 | 11 | embedded_schema do 12 | field(:nick, :string) 13 | field(:roles, {:array, Snowflake}) 14 | field(:joined_at, :utc_datetime) 15 | field(:premium_since, :utc_datetime) 16 | field(:deaf, :boolean) 17 | field(:mute, :boolean) 18 | field(:pending, :boolean) 19 | field(:permissions, :integer) 20 | 21 | field(:voice_state, :any, virtual: true) 22 | 23 | belongs_to(:user, User, primary_key: true) 24 | belongs_to(:guild, Guild, primary_key: true) 25 | end 26 | 27 | def fetch({user_id, guild_id}, options) do 28 | with {:ok, object} <- API.get("guilds/#{guild_id}/members/#{user_id}", options) do 29 | object = Map.put(object, "guild_id", guild_id) 30 | {:ok, object} 31 | end 32 | end 33 | 34 | def patch({user_id, guild_id}, params, options) do 35 | with {:ok, object} <- API.patch("guilds/#{guild_id}/members/#{user_id}", params, options) do 36 | object = Map.put(object, "guild_id", guild_id) 37 | {:ok, object} 38 | end 39 | end 40 | 41 | def drop({user_id, guild_id}, options) do 42 | API.delete("guilds/#{guild_id}/members/#{user_id}", options) 43 | end 44 | 45 | def preload(%Member{roles: [%Role{} | _rest] = roles} = member, :roles, options) do 46 | if options[:force] do 47 | roles = Enum.map(roles, & &1.id) 48 | member = %{member | roles: roles} 49 | preload(member, :roles, options) 50 | else 51 | member 52 | end 53 | end 54 | 55 | def preload(%Member{guild_id: guild_id, roles: roles} = member, :roles, options) do 56 | roles = 57 | roles 58 | |> Stream.map(&{&1, guild_id}) 59 | |> Stream.map(&Role.get(&1, options)) 60 | |> Enum.to_list() 61 | 62 | %{member | roles: roles} 63 | end 64 | 65 | def preload(%Member{voice_state: %VoiceState{}} = member, :voice_state, options) do 66 | if options[:force] do 67 | member = %{member | voice_state: nil} 68 | preload(member, :voice_state, options) 69 | else 70 | member 71 | end 72 | end 73 | 74 | def preload(%Member{user_id: user_id, guild_id: guild_id} = member, :voice_state, options) do 75 | voice_state = VoiceState.get({user_id, guild_id}, options) 76 | %{member | voice_state: voice_state} 77 | end 78 | 79 | def preload(member, association, options) do 80 | super(member, association, options) 81 | end 82 | 83 | @spec add_role(t, Role.t() | Snowflake.t(), Loader.options()) :: Loader.result() 84 | def add_role(member, role, options \\ []) 85 | 86 | def add_role(member, %Role{id: role_id}, options) do 87 | add_role(member, role_id, options) 88 | end 89 | 90 | def add_role(%Member{user_id: user_id, guild_id: guild_id}, role_id, options) do 91 | API.put("guilds/#{guild_id}/members/#{user_id}/roles/#{role_id}", options) 92 | end 93 | 94 | @spec remove_role(t, Role.t() | Snowflake.t(), Loader.options()) :: Loader.result() 95 | def remove_role(member, role, options \\ []) 96 | 97 | def remove_role(member, %Role{id: id}, options) do 98 | remove_role(member, id, options) 99 | end 100 | 101 | def remove_role(%Member{user_id: user_id, guild_id: guild_id}, role_id, options) do 102 | API.delete("guilds/#{guild_id}/members/#{user_id}/roles/#{role_id}", options) 103 | end 104 | 105 | @spec has_role?(t, Role.t() | Snowflake.t(), Loader.options()) :: boolean 106 | def has_role?(member, role, options \\ []) 107 | 108 | def has_role?(member, %Role{id: role_id}, options) do 109 | has_role?(member, role_id, options) 110 | end 111 | 112 | def has_role?(member, role_id, options) do 113 | member = preload!(member, :roles, options) 114 | %Member{roles: roles} = member 115 | 116 | roles 117 | |> Stream.map(& &1.id) 118 | |> Enum.find_value(false, &(&1 == role_id)) 119 | end 120 | 121 | @spec kick(t, Loader.options()) :: Loader.result() 122 | def kick(member, options \\ []) do 123 | delete(member, options) 124 | end 125 | 126 | @spec ban(t, Enum.t(), Loader.options()) :: Loader.result() 127 | def ban(%Member{user_id: user_id, guild_id: guild_id}, params, options \\ []) do 128 | params 129 | |> Map.new() 130 | |> Map.put(:user_id, user_id) 131 | |> Map.put(:guild_id, guild_id) 132 | |> Ban.create(options) 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/guild.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Guild do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use Coxir.Model 6 | 7 | @type t :: %Guild{} 8 | 9 | embedded_schema do 10 | field(:name, :string) 11 | field(:icon, :string) 12 | field(:splash, :string) 13 | field(:discovery_splash, :string) 14 | field(:permissions, :integer) 15 | field(:region, :string) 16 | field(:afk_timeout, :integer) 17 | field(:widget_enabled, :boolean) 18 | field(:verification_level, :integer) 19 | field(:default_message_notifications, :integer) 20 | field(:explicit_content_filter, :integer) 21 | field(:features, {:array, :string}) 22 | field(:mfa_level, :integer) 23 | field(:application_id, Snowflake) 24 | field(:system_channel_flags, :integer) 25 | field(:joined_at, :utc_datetime) 26 | field(:large, :boolean) 27 | field(:unavailable, :boolean) 28 | field(:member_count, :integer) 29 | field(:max_presences, :integer) 30 | field(:max_members, :integer) 31 | field(:vanity_url_code, :string) 32 | field(:description, :string) 33 | field(:banner, :string) 34 | field(:premium_tier, :integer) 35 | field(:premium_subscription_count, :integer) 36 | field(:preferred_locale, :string) 37 | field(:max_video_channel_users, :integer) 38 | 39 | embeds_many(:emojis, Emoji) 40 | 41 | belongs_to(:owner, User) 42 | belongs_to(:afk_channel, Channel) 43 | belongs_to(:widget_channel, Channel) 44 | belongs_to(:system_channel, Channel) 45 | belongs_to(:rules_channel, Channel) 46 | belongs_to(:public_updates_channel, Channel) 47 | 48 | has_many(:roles, Role) 49 | has_many(:bans, Ban) 50 | has_many(:members, Member) 51 | has_many(:voice_states, VoiceState) 52 | end 53 | 54 | def fetch(id, options) do 55 | API.get("guilds/#{id}", options) 56 | end 57 | 58 | def fetch_many(id, :roles, options) do 59 | with {:ok, objects} <- API.get("guilds/#{id}/roles", options) do 60 | objects = Enum.map(objects, &Map.put(&1, "guild_id", id)) 61 | {:ok, objects} 62 | end 63 | end 64 | 65 | def fetch_many(id, :bans, options) do 66 | with {:ok, objects} <- API.get("guilds/#{id}/bans", options) do 67 | objects = Enum.map(objects, &Map.put(&1, "guild_id", id)) 68 | {:ok, objects} 69 | end 70 | end 71 | 72 | def patch(id, params, options) do 73 | API.patch("guilds/#{id}", params, options) 74 | end 75 | 76 | def drop(id, options) do 77 | API.delete("guilds/#{id}", options) 78 | end 79 | 80 | @doc """ 81 | Delegates to `Coxir.Member.get/2`. 82 | """ 83 | @spec get_member(t, User.t() | Snowflake.t(), Loader.options()) :: Member.t() | Error.t() 84 | def get_member(guild, user, options \\ []) 85 | 86 | def get_member(guild, %User{id: user_id}, options) do 87 | get_member(guild, user_id, options) 88 | end 89 | 90 | def get_member(%Guild{id: id}, user_id, options) do 91 | Member.get({user_id, id}, options) 92 | end 93 | 94 | @spec get_prune_count(t, Loader.options()) :: non_neg_integer | Error.t() 95 | def get_prune_count(%Guild{id: id}, options \\ []) do 96 | query = Keyword.take(options, [:days, :include_roles]) 97 | 98 | case API.get("guilds/#{id}/prune", query, options) do 99 | {:ok, %{"pruned" => pruned}} -> 100 | pruned 101 | 102 | {:error, error} -> 103 | error 104 | end 105 | end 106 | 107 | @spec prune(t, Enum.t(), Loader.options()) :: {:ok, non_neg_integer | nil} | Loader.result() 108 | def prune(%Guild{id: id}, params \\ %{}, options \\ []) do 109 | params = Map.new(params) 110 | result = API.post("guilds/#{id}/prune", params, options) 111 | 112 | with {:ok, %{"pruned" => pruned}} <- result do 113 | {:ok, pruned} 114 | end 115 | end 116 | 117 | @doc """ 118 | Delegates to `Coxir.Ban.get/2`. 119 | """ 120 | @spec get_ban(t, User.t() | Snowflake.t(), Loader.options()) :: Ban.t() | Error.t() 121 | def get_ban(guild, user, options \\ []) 122 | 123 | def get_ban(guild, %User{id: user_id}, options) do 124 | get_ban(guild, user_id, options) 125 | end 126 | 127 | def get_ban(%Guild{id: id}, user_id, options) do 128 | Ban.get({user_id, id}, options) 129 | end 130 | 131 | @doc """ 132 | Delegates to `Coxir.Role.get/2`. 133 | """ 134 | @spec get_role(t, Snowflake.t(), Loader.options()) :: Role.t() | Error.t() 135 | def get_role(%Guild{id: id}, role_id, options \\ []) do 136 | Role.get({role_id, id}, options) 137 | end 138 | 139 | @spec create_channel(t, Enum.t(), Loader.options()) :: Loader.result() 140 | def create_channel(%Guild{id: id}, params, options \\ []) do 141 | params 142 | |> Map.new() 143 | |> Map.put(:guild_id, id) 144 | |> Channel.create(options) 145 | end 146 | 147 | @spec create_role(t, Enum.t(), Loader.options()) :: Loader.result() 148 | def create_role(%Guild{id: id}, params, options \\ []) do 149 | params 150 | |> Map.new() 151 | |> Map.put(:guild_id, id) 152 | |> Role.create(options) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/coxir/model.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Model do 2 | @moduledoc """ 3 | The base behaviour for entities. 4 | """ 5 | alias Macro.Env 6 | alias Coxir.API 7 | alias Coxir.API.Error 8 | alias Coxir.Model.{Snowflake, Loader} 9 | 10 | @typedoc """ 11 | A module that implements the behaviour. 12 | """ 13 | @type model :: module 14 | 15 | @typedoc """ 16 | A struct object of a given `t:model/0`. 17 | """ 18 | @type instance :: struct 19 | 20 | @typedoc """ 21 | The internal coxir identificator for a `t:instance/0`. 22 | 23 | Matches the primary key of the `t:model/0`. 24 | 25 | If the primary key has many fields, they appear in the order they are defined. 26 | """ 27 | @type key :: Snowflake.t() | tuple 28 | 29 | @doc """ 30 | Called to fetch a `t:instance/0` from the API. 31 | """ 32 | @callback fetch(key, keyword) :: API.result() 33 | 34 | @doc """ 35 | Called to fetch a `Ecto.Schema.has_many/3` association of a `t:instance/0` from the API. 36 | """ 37 | @callback fetch_many(key, atom, keyword) :: API.result() 38 | 39 | @doc """ 40 | Called to create a `t:instance/0` through the API. 41 | """ 42 | @callback insert(map, keyword) :: API.result() 43 | 44 | @doc """ 45 | Called to update a `t:instance/0` through the API. 46 | """ 47 | @callback patch(key, map, keyword) :: API.result() 48 | 49 | @doc """ 50 | Called to delete a `t:instance/0` through the API. 51 | """ 52 | @callback drop(key, keyword) :: API.result() 53 | 54 | @doc """ 55 | Returns whether a `t:model/0` is hardcoded to be stored. 56 | """ 57 | @callback storable?() :: boolean 58 | 59 | @doc """ 60 | Delegates to `Coxir.Model.Loader.get/3`. 61 | """ 62 | @callback get(key, Loader.options()) :: instance | Error.t() 63 | 64 | @doc """ 65 | Delegates to `Coxir.Model.Loader.get!/3`. 66 | """ 67 | @callback get!(key, Loader.options()) :: instance 68 | 69 | @doc """ 70 | Delegates to `Coxir.Model.Loader.preload/3`. 71 | """ 72 | @callback preload(instance, Loader.preloads(), Loader.options()) :: instance 73 | 74 | @callback preload(list(instance), Loader.preloads(), Loader.options()) :: list(instance) 75 | 76 | @doc """ 77 | Delegates to `Coxir.Model.Loader.preload!/3`. 78 | """ 79 | @callback preload!(instance, Loader.preloads(), Loader.options()) :: instance 80 | 81 | @callback preload!(list(instance), Loader.preloads(), Loader.options()) :: list(instance) 82 | 83 | @doc """ 84 | Delegates to `Coxir.Model.Loader.create/3`. 85 | """ 86 | @callback create(Enum.t(), Loader.options()) :: Loader.result() 87 | 88 | @doc """ 89 | Delegates to `Coxir.Model.Loader.update/3`. 90 | """ 91 | @callback update(instance, Enum.t(), Loader.options()) :: Loader.result() 92 | 93 | @doc """ 94 | Delegates to `Coxir.Model.Loader.delete/2`. 95 | """ 96 | @callback delete(instance, Loader.options()) :: Loader.result() 97 | 98 | @optional_callbacks [fetch: 2, fetch_many: 3, insert: 2, patch: 3, drop: 2, preload: 3] 99 | 100 | defmacro __using__(options \\ []) do 101 | storable? = Keyword.get(options, :storable?, true) 102 | 103 | quote location: :keep do 104 | use Ecto.Schema 105 | 106 | alias Coxir.API 107 | alias Coxir.API.Error 108 | alias Coxir.Model.{Snowflake, Loader} 109 | alias Coxir.{User, Channel, Guild} 110 | alias Coxir.{Invite, Overwrite, Webhook} 111 | alias Coxir.{Message, Emoji, Reaction, Interaction} 112 | alias Coxir.{Integration, Role, Ban, Member, Presence, VoiceState} 113 | alias Ecto.Association.NotLoaded 114 | alias __MODULE__ 115 | 116 | @storable unquote(storable?) 117 | 118 | @before_compile Coxir.Model 119 | @behaviour Coxir.Model 120 | 121 | @primary_key {:id, Snowflake, []} 122 | @foreign_key_type Snowflake 123 | 124 | @doc false 125 | def storable?, do: @storable 126 | 127 | def get(key, options \\ []) do 128 | Loader.get(__MODULE__, key, options) 129 | end 130 | 131 | def get!(key, options \\ []) do 132 | Loader.get!(__MODULE__, key, options) 133 | end 134 | 135 | def preload(struct, preloads, options \\ []) do 136 | Loader.preload(struct, preloads, options) 137 | end 138 | 139 | def preload!(struct, preloads, options \\ []) do 140 | Loader.preload!(struct, preloads, options) 141 | end 142 | 143 | def create(params, options \\ []) do 144 | Loader.create(__MODULE__, params, options) 145 | end 146 | 147 | def update(struct, params, options \\ []) do 148 | Loader.update(struct, params, options) 149 | end 150 | 151 | def delete(struct, options \\ []) do 152 | Loader.delete(struct, options) 153 | end 154 | 155 | defoverridable(preload: 3, delete: 2) 156 | end 157 | end 158 | 159 | defmacro __before_compile__(%Env{module: model}) do 160 | storable? = Module.get_attribute(model, :storable) 161 | fetch? = Module.defines?(model, {:fetch, 2}) 162 | insert? = Module.defines?(model, {:insert, 2}) 163 | patch? = Module.defines?(model, {:patch, 3}) 164 | drop? = Module.defines?(model, {:drop, 2}) 165 | 166 | ecto_assocs = Module.get_attribute(model, :ecto_assocs) 167 | preload? = length(ecto_assocs) > 0 168 | 169 | get = (storable? or fetch?) && nil 170 | preload = preload? && nil 171 | create = insert? && nil 172 | update = patch? && nil 173 | delete = drop? && nil 174 | 175 | quote location: :keep do 176 | @doc false 177 | def fetch(key, options) 178 | 179 | @doc false 180 | def fetch_many(key, association, options) 181 | 182 | @doc false 183 | def insert(params, options) 184 | 185 | @doc false 186 | def patch(key, params, options) 187 | 188 | @doc false 189 | def drop(key, options) 190 | 191 | @doc unquote(get) 192 | def get(key, options) 193 | 194 | @doc unquote(get) 195 | def get!(key, options) 196 | 197 | @doc unquote(preload) 198 | def preload(struct, preloads, options) 199 | 200 | @doc unquote(preload) 201 | def preload!(struct, preloads, options) 202 | 203 | @doc unquote(create) 204 | def create(params, options) 205 | 206 | @doc unquote(update) 207 | def update(struct, params, options) 208 | 209 | @doc unquote(delete) 210 | def delete(struct, options) 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/coxir/voice.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice do 2 | @moduledoc """ 3 | Allows for interaction with Discord voice channels. 4 | """ 5 | use Supervisor 6 | 7 | alias Coxir.Gateway 8 | alias Coxir.Gateway.Session 9 | alias Coxir.Gateway.Payload.UpdateVoiceState 10 | alias Coxir.{Guild, Channel, VoiceState} 11 | alias Coxir.Voice.Instance 12 | alias Coxir.Player 13 | alias __MODULE__ 14 | 15 | @typedoc """ 16 | The options that must be passed to `join/2`. 17 | """ 18 | @type join_options :: [ 19 | as: Gateway.gateway(), 20 | self_deaf: boolean | false, 21 | self_mute: boolean | false 22 | ] 23 | 24 | @typedoc """ 25 | The options that can be passed to `play/3`. 26 | """ 27 | @type play_options :: [player: Player.t() | Player.Default] | Player.options() 28 | 29 | @typedoc """ 30 | The options that must be passed to `leave/2`. 31 | """ 32 | @type leave_options :: [as: Gateway.gateway()] 33 | 34 | @doc """ 35 | Joins a given voice channel. 36 | 37 | If the user is already in the channel, the function acts as a no-op. 38 | 39 | If the user is in a different channel of the same guild, it will stop playing and then switch. 40 | """ 41 | @spec join(Channel.t(), join_options) :: Instance.instance() 42 | def join(%Channel{id: channel_id, guild_id: guild_id}, options) do 43 | gateway = Keyword.fetch!(options, :as) 44 | user_id = Gateway.get_user_id(gateway) 45 | 46 | instance = ensure_instance(gateway, user_id, guild_id) 47 | has_endpoint? = Instance.has_endpoint?(instance) 48 | same_channel? = Instance.get_channel_id(instance) == channel_id 49 | 50 | if not has_endpoint? or not same_channel? do 51 | update_voice_state(gateway, guild_id, channel_id, options) 52 | end 53 | 54 | if not same_channel? do 55 | stop_playing(instance) 56 | end 57 | 58 | instance 59 | end 60 | 61 | @doc """ 62 | Begins playing audio on a given instance. 63 | 64 | Refer to the documentation of the player in use for more information on the arguments. 65 | 66 | If no custom player is provided, the default `Coxir.Player.Default` will be used. 67 | """ 68 | @spec play(Instance.instance(), Player.playable(), play_options) :: :ok | {:error, term} 69 | def play(instance, playable, options \\ []) do 70 | player_module = Keyword.get(options, :player, Player.Default) 71 | Instance.play(instance, player_module, playable, options) 72 | end 73 | 74 | @doc """ 75 | Pauses audio playback on a given instance. 76 | """ 77 | @spec pause(Instance.instance()) :: :ok | {:error, :no_player} 78 | def pause(instance) do 79 | Instance.pause(instance) 80 | end 81 | 82 | @doc """ 83 | Resumes audio playback on a given instance. 84 | """ 85 | @spec resume(Instance.instance()) :: :ok | {:error, :no_player} 86 | def resume(instance) do 87 | Instance.resume(instance) 88 | end 89 | 90 | @doc """ 91 | Returns whether audio is currently playing on a given instance. 92 | """ 93 | @spec playing?(Instance.instance()) :: boolean 94 | def playing?(instance) do 95 | Instance.playing?(instance) 96 | end 97 | 98 | @doc """ 99 | Stops playing audio on a given instance. 100 | """ 101 | @spec stop_playing(Instance.instance()) :: :ok 102 | def stop_playing(instance) do 103 | Instance.stop_playing(instance) 104 | end 105 | 106 | @doc """ 107 | Leaves from a given voice channel, or the active voice channel for a guild. 108 | """ 109 | @spec leave(Guild.t() | Channel.t(), leave_options) :: :ok 110 | def leave(%Guild{id: guild_id}, options) do 111 | channel = %Channel{guild_id: guild_id} 112 | leave(channel, options) 113 | end 114 | 115 | def leave(%Channel{guild_id: guild_id}, as: gateway) do 116 | user_id = Gateway.get_user_id(gateway) 117 | 118 | terminate_instance(user_id, guild_id) 119 | 120 | update_voice_state(gateway, guild_id, nil) 121 | end 122 | 123 | @doc false 124 | @spec update_voice_state( 125 | Gateway.gateway(), 126 | Snowflake.t() | nil, 127 | Snowflake.t() | nil, 128 | join_options 129 | ) :: :ok 130 | def update_voice_state(gateway, guild_id, channel_id, options \\ []) do 131 | channel = %Channel{id: channel_id, guild_id: guild_id} 132 | session = Gateway.get_shard(gateway, channel) 133 | 134 | params = 135 | options 136 | |> Map.new() 137 | |> Map.put(:guild_id, guild_id) 138 | |> Map.put(:channel_id, channel_id) 139 | 140 | update_voice_state = UpdateVoiceState.cast(params) 141 | 142 | Session.update_voice_state(session, update_voice_state) 143 | end 144 | 145 | @doc false 146 | def update(user_id, guild_id, %VoiceState{channel_id: nil}) do 147 | terminate_instance(user_id, guild_id) 148 | end 149 | 150 | def update(user_id, guild_id, struct) do 151 | if instance = get_instance(user_id, guild_id) do 152 | Instance.update(instance, struct) 153 | end 154 | end 155 | 156 | @doc false 157 | def child_spec(term) do 158 | super(term) 159 | end 160 | 161 | @doc false 162 | def start_link(state) do 163 | Supervisor.start_link(__MODULE__, state, name: Voice) 164 | end 165 | 166 | @doc false 167 | def init(_state) do 168 | Supervisor.init([], strategy: :one_for_one) 169 | end 170 | 171 | defp get_instance(user_id, guild_id) do 172 | children = Supervisor.which_children(Voice) 173 | 174 | Enum.find_value( 175 | children, 176 | fn {id, pid, _type, _modules} -> 177 | if id == {user_id, guild_id}, do: pid 178 | end 179 | ) 180 | end 181 | 182 | defp ensure_instance(gateway, user_id, guild_id) do 183 | instance_spec = generate_instance_spec(gateway, user_id, guild_id) 184 | 185 | case Supervisor.start_child(Voice, instance_spec) do 186 | {:ok, instance} -> 187 | instance 188 | 189 | {:error, {:already_started, instance}} -> 190 | instance 191 | 192 | {:error, :already_present} -> 193 | terminate_instance(user_id, guild_id) 194 | ensure_instance(gateway, user_id, guild_id) 195 | end 196 | end 197 | 198 | defp terminate_instance(user_id, guild_id) do 199 | Supervisor.terminate_child(Voice, {user_id, guild_id}) 200 | Supervisor.delete_child(Voice, {user_id, guild_id}) 201 | end 202 | 203 | defp generate_instance_spec(gateway, user_id, guild_id) do 204 | state = %Instance{ 205 | gateway: gateway, 206 | user_id: user_id, 207 | guild_id: guild_id 208 | } 209 | 210 | spec = Instance.child_spec(state) 211 | 212 | %{spec | id: {user_id, guild_id}} 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.10", "b01a007416a0ae4188e70b3b306236021b16c11474038ead7aff79dd75538c23", [:mix], [], "hexpm", "a48314e0cb45682db2ea27b8ebfa11bd6fa0a6e21a65e5772ad83ca136ff2665"}, 3 | "chacha20": {:hex, :chacha20, "1.0.2", "73c5e96eba5e94917603a43c5c7c6b049436a5d71d9d3182781c345d87d28c8b", [:mix], [], "hexpm", "549b89314cbffa0893ef923d999d625c227cab5f1301133598793225f02a3963"}, 4 | "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, 5 | "curve25519": {:hex, :curve25519, "1.0.4", "e570561b832c29b3ce4fd8b9fcd9c9546916188860568f1c1fc9428d7cb00894", [:mix], [], "hexpm", "1a068bf9646e7067bf6aa5bf802b404002734e09bb5300f098109df03e31f9f5"}, 6 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, 8 | "ecto": {:hex, :ecto, "3.6.1", "7bb317e3fd0179ad725069fd0fe8a28ebe48fec6282e964ea502e4deccb0bd0f", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbb3294a990447b19f0725488a749f8cf806374e0d9d0dffc45d61e7aeaf6553"}, 9 | "ed25519": {:hex, :ed25519, "1.3.2", "e3a2d4badf57f0799279cf09925bd761ec38df6df3696e266585626280b5c0ad", [:mix], [], "hexpm", "2290e46e0e23717adbe20632c6dd29aa71a46ca6e153ef7ba41fe1204f66f859"}, 10 | "equivalex": {:hex, :equivalex, "1.0.2", "b9a9aaf79f2556288f514218653beaddb15afa2af407bfec37c5c4906e39f514", [:mix], [], "hexpm", "f7f8127c59be715ee6288f8c59fa8fc40e6428fb5c9bd2a001de2c9b1ff3f1c2"}, 11 | "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, 12 | "gen_stage": {:hex, :gen_stage, "1.1.0", "dd0c0f8d2f3b993fdbd3d58e94abbe65380f4e78bdee3fa93d5618d7d14abe60", [:mix], [], "hexpm", "7f2b36a6d02f7ef2ba410733b540ec423af65ec9c99f3d1083da508aca3b9305"}, 13 | "gun": {:hex, :gun, "1.3.3", "cf8b51beb36c22b9c8df1921e3f2bc4d2b1f68b49ad4fbc64e91875aa14e16b4", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3106ce167f9c9723f849e4fb54ea4a4d814e3996ae243a1c828b256e749041e0"}, 14 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 15 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 16 | "kcl": {:hex, :kcl, "1.3.2", "40ca3e6c6421781b6db8ba2e5354e64c975f19a3073aed62f632e6edcb714148", [:mix], [{:curve25519, ">= 1.0.4", [hex: :curve25519, repo: "hexpm", optional: false]}, {:ed25519, "~> 1.3", [hex: :ed25519, repo: "hexpm", optional: false]}, {:poly1305, "~> 1.0", [hex: :poly1305, repo: "hexpm", optional: false]}, {:salsa20, "~> 1.0", [hex: :salsa20, repo: "hexpm", optional: false]}], "hexpm", "f66d34ef4c9be59fa439e8ca861daed074e6542306a81dabe8e3ab2be9dc78fd"}, 17 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 20 | "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 22 | "poly1305": {:hex, :poly1305, "1.0.2", "c6bb74cbe79747cc12aa1580791c2bd8e0f062bc8faaf117b756e675cfaea03d", [:mix], [{:chacha20, "~> 1.0", [hex: :chacha20, repo: "hexpm", optional: false]}, {:equivalex, "~> 1.0", [hex: :equivalex, repo: "hexpm", optional: false]}], "hexpm", "d0a0f8be324e7bfdd61e8e52215024a025816f3a7f665c274ad5bea154480c2b"}, 23 | "porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm", "dc996ab8fadbc09912c787c7ab8673065e50ea1a6245177b0c24569013d23620"}, 24 | "salsa20": {:hex, :salsa20, "1.0.2", "558fddb4169b1f90b1748d42e9221ba8d1354c414a03361308cc7bc0fd2f45c7", [:mix], [], "hexpm", "8d0d394c67da8d44073cbf2c030c4e0e04e7bed92967761be60419d8d46df18d"}, 25 | "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, 26 | "tesla": {:hex, :tesla, "1.4.1", "ff855f1cac121e0d16281b49e8f066c4a0d89965f98864515713878cca849ac8", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "95f5de35922c8c4b3945bee7406f66eb680b0955232f78f5fb7e853aa1ce201a"}, 27 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 28 | } 29 | -------------------------------------------------------------------------------- /lib/coxir/gateway/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Session do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use GenServer 6 | 7 | alias Coxir.Gateway.{Payload, Producer} 8 | alias Coxir.Gateway.Payload.{Hello, Identify, Resume} 9 | alias Coxir.Gateway.Payload.{RequestGuildMembers, UpdateVoiceState, UpdatePresence} 10 | alias __MODULE__ 11 | 12 | defstruct [ 13 | :user_id, 14 | :token, 15 | :intents, 16 | :producer, 17 | :gateway_host, 18 | :shard, 19 | :gun_pid, 20 | :stream_ref, 21 | :zlib_context, 22 | :heartbeat_ref, 23 | :heartbeat_ack, 24 | :sequence, 25 | :session_id 26 | ] 27 | 28 | @query "/?v=8&encoding=json&compress=zlib-stream" 29 | 30 | @close_raise [4010, 4011, 4014] 31 | @close_session [4007, 4009] 32 | 33 | @connect {:continue, :connect} 34 | @reconnect {:continue, :reconnect} 35 | @identify {:continue, :identify} 36 | 37 | @type session :: pid 38 | 39 | def update_presence(session, %UpdatePresence{} = payload) do 40 | GenServer.call(session, {:send_command, :PRESENCE_UPDATE, payload}) 41 | end 42 | 43 | def update_voice_state(session, %UpdateVoiceState{} = payload) do 44 | GenServer.call(session, {:send_command, :VOICE_STATE_UPDATE, payload}) 45 | end 46 | 47 | def request_guild_members(session, %RequestGuildMembers{} = payload) do 48 | GenServer.call(session, {:send_command, :REQUEST_GUILD_MEMBERS, payload}) 49 | end 50 | 51 | def start_link(state) do 52 | GenServer.start_link(__MODULE__, state) 53 | end 54 | 55 | def init(state) do 56 | {:ok, state, @connect} 57 | end 58 | 59 | def handle_continue(:connect, %Session{gateway_host: gateway_host} = state) do 60 | {:ok, gun_pid} = :gun.open(gateway_host, 443, %{protocols: [:http]}) 61 | state = %{state | gun_pid: gun_pid} 62 | {:noreply, state} 63 | end 64 | 65 | def handle_continue( 66 | :reconnect, 67 | %Session{gun_pid: gun_pid, heartbeat_ref: heartbeat_ref} = state 68 | ) do 69 | :ok = :gun.close(gun_pid) 70 | :timer.cancel(heartbeat_ref) 71 | 72 | {:noreply, state, @connect} 73 | end 74 | 75 | def handle_continue( 76 | :identify, 77 | %Session{session_id: nil, token: token, shard: shard, intents: intents} = state 78 | ) do 79 | identify = %Identify{ 80 | token: token, 81 | shard: shard, 82 | intents: intents, 83 | compress: true 84 | } 85 | 86 | send_command(:IDENTIFY, identify, state) 87 | 88 | {:noreply, state} 89 | end 90 | 91 | def handle_continue( 92 | :identify, 93 | %Session{session_id: session_id, token: token, sequence: sequence} = state 94 | ) do 95 | resume = %Resume{ 96 | token: token, 97 | session_id: session_id, 98 | sequence: sequence 99 | } 100 | 101 | send_command(:RESUME, resume, state) 102 | 103 | {:noreply, state} 104 | end 105 | 106 | def handle_call({:send_command, operation, data}, _from, state) do 107 | result = send_command(operation, data, state) 108 | {:reply, result, state} 109 | end 110 | 111 | def handle_info(:heartbeat, %Session{sequence: sequence, heartbeat_ack: true} = state) do 112 | send_command(:HEARTBEAT, sequence, state) 113 | state = %{state | heartbeat_ack: false} 114 | {:noreply, state} 115 | end 116 | 117 | def handle_info(:heartbeat, %Session{heartbeat_ack: false} = state) do 118 | {:noreply, state, @reconnect} 119 | end 120 | 121 | def handle_info({:gun_up, gun_pid, :http}, %Session{gun_pid: gun_pid} = state) do 122 | stream_ref = :gun.ws_upgrade(gun_pid, @query) 123 | state = %{state | stream_ref: stream_ref} 124 | {:noreply, state} 125 | end 126 | 127 | def handle_info( 128 | {:gun_upgrade, gun_pid, stream_ref, ["websocket"], _headers}, 129 | %Session{gun_pid: gun_pid, stream_ref: stream_ref, zlib_context: zlib_context} = state 130 | ) do 131 | zlib_context = 132 | if is_nil(zlib_context) do 133 | zlib_context = :zlib.open() 134 | :zlib.inflateInit(zlib_context) 135 | zlib_context 136 | else 137 | :zlib.inflateReset(zlib_context) 138 | zlib_context 139 | end 140 | 141 | state = %{state | zlib_context: zlib_context} 142 | {:noreply, state} 143 | end 144 | 145 | def handle_info( 146 | {:gun_ws, gun_pid, stream_ref, frame}, 147 | %Session{gun_pid: gun_pid, stream_ref: stream_ref} = state 148 | ) do 149 | handle_frame(frame, state) 150 | end 151 | 152 | def handle_info( 153 | {:gun_response, gun_pid, stream_ref, _is_fin, _status, _headers}, 154 | %Session{gun_pid: gun_pid, stream_ref: stream_ref} = state 155 | ) do 156 | {:noreply, state, @reconnect} 157 | end 158 | 159 | def handle_info( 160 | {:gun_error, gun_pid, stream_ref, _reason}, 161 | %Session{gun_pid: gun_pid, stream_ref: stream_ref} = state 162 | ) do 163 | {:noreply, state, @reconnect} 164 | end 165 | 166 | def handle_info( 167 | {:gun_down, gun_pid, _protocol, _reason, _killed, _unprocessed}, 168 | %Session{gun_pid: gun_pid} = state 169 | ) do 170 | {:noreply, state, @reconnect} 171 | end 172 | 173 | defp handle_frame( 174 | {:binary, frame}, 175 | %Session{zlib_context: zlib_context, user_id: user_id} = state 176 | ) do 177 | zlib_context 178 | |> :zlib.inflate(frame) 179 | |> Jason.decode!() 180 | |> Payload.cast(user_id) 181 | |> handle_payload(state) 182 | end 183 | 184 | defp handle_frame({:close, status, reason}, _state) when status in @close_raise do 185 | raise(reason) 186 | end 187 | 188 | defp handle_frame({:close, status, _reason}, state) when status in @close_session do 189 | state = %{state | session_id: nil} 190 | {:noreply, state, @reconnect} 191 | end 192 | 193 | defp handle_frame({:close, _status, _reason}, state) do 194 | {:noreply, state, @reconnect} 195 | end 196 | 197 | defp handle_payload(%Payload{operation: :HELLO, data: data}, state) do 198 | %Hello{heartbeat_interval: heartbeat_interval} = Hello.cast(data) 199 | 200 | {:ok, heartbeat_ref} = :timer.send_interval(heartbeat_interval, self(), :heartbeat) 201 | 202 | state = %{state | heartbeat_ref: heartbeat_ref, heartbeat_ack: true} 203 | {:noreply, state, @identify} 204 | end 205 | 206 | defp handle_payload(%Payload{operation: :RECONNECT}, state) do 207 | {:noreply, state, @reconnect} 208 | end 209 | 210 | defp handle_payload(%Payload{operation: :INVALID_SESSION}, state) do 211 | state = %{state | session_id: nil} 212 | {:noreply, state, @identify} 213 | end 214 | 215 | defp handle_payload( 216 | %Payload{operation: :DISPATCH, data: data, sequence: sequence} = payload, 217 | %Session{producer: producer, session_id: session_id} = state 218 | ) do 219 | Producer.notify(producer, payload) 220 | 221 | session_id = Map.get(data, "session_id", session_id) 222 | state = %{state | session_id: session_id, sequence: sequence} 223 | {:noreply, state} 224 | end 225 | 226 | defp handle_payload(%Payload{operation: :HEARTBEAT_ACK}, state) do 227 | state = %{state | heartbeat_ack: true} 228 | {:noreply, state} 229 | end 230 | 231 | defp handle_payload(_payload, state) do 232 | {:noreply, state} 233 | end 234 | 235 | defp send_command(operation, data, %Session{gun_pid: gun_pid}) do 236 | payload = %Payload{operation: operation, data: data} 237 | command = Payload.to_command(payload) 238 | binary = Jason.encode!(command) 239 | message = {:binary, binary} 240 | 241 | :ok = :gun.ws_send(gun_pid, message) 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /lib/coxir/voice/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Session do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use GenServer 6 | 7 | alias Coxir.Voice.Payload.{Hello, Ready, SessionDescription} 8 | alias Coxir.Voice.Payload.{Identify, Resume, SelectProtocol, Speaking} 9 | alias Coxir.Voice.{Payload, Audio, Instance} 10 | alias __MODULE__ 11 | 12 | defstruct [ 13 | :instance, 14 | :user_id, 15 | :guild_id, 16 | :session_id, 17 | :endpoint_host, 18 | :endpoint_port, 19 | :token, 20 | :gun_pid, 21 | :stream_ref, 22 | :heartbeat_ref, 23 | :heartbeat_nonce, 24 | :heartbeat_ack, 25 | {:been_ready?, false}, 26 | :udp_socket, 27 | :audio_ip, 28 | :audio_port, 29 | :ssrc, 30 | :secret_key 31 | ] 32 | 33 | @query "/?v=4" 34 | 35 | @close_session [4006, 4009] 36 | @close_dropped [4014] 37 | 38 | @connect {:continue, :connect} 39 | @reconnect {:continue, :reconnect} 40 | @identify {:continue, :identify} 41 | @update_instance {:continue, :update_instance} 42 | 43 | def set_speaking(session, %Speaking{} = speaking) do 44 | GenServer.cast(session, {:send_command, :SPEAKING, speaking}) 45 | end 46 | 47 | def start_link(state) do 48 | GenServer.start_link(__MODULE__, state) 49 | end 50 | 51 | def init(state) do 52 | {:ok, state, @connect} 53 | end 54 | 55 | def handle_continue( 56 | :connect, 57 | %Session{endpoint_host: endpoint_host, endpoint_port: endpoint_port} = state 58 | ) do 59 | {:ok, gun_pid} = :gun.open(endpoint_host, endpoint_port, %{protocols: [:http]}) 60 | state = %{state | gun_pid: gun_pid} 61 | {:noreply, state} 62 | end 63 | 64 | def handle_continue( 65 | :reconnect, 66 | %Session{gun_pid: gun_pid, heartbeat_ref: heartbeat_ref} = state 67 | ) do 68 | :ok = :gun.close(gun_pid) 69 | :timer.cancel(heartbeat_ref) 70 | 71 | {:noreply, state, @connect} 72 | end 73 | 74 | def handle_continue( 75 | :identify, 76 | %Session{ 77 | user_id: user_id, 78 | guild_id: guild_id, 79 | session_id: session_id, 80 | token: token, 81 | been_ready?: false 82 | } = state 83 | ) do 84 | identify = %Identify{ 85 | user_id: user_id, 86 | server_id: guild_id, 87 | session_id: session_id, 88 | token: token 89 | } 90 | 91 | send_command(:IDENTIFY, identify, state) 92 | 93 | {:noreply, state} 94 | end 95 | 96 | def handle_continue( 97 | :identify, 98 | %Session{guild_id: guild_id, session_id: session_id, token: token} = state 99 | ) do 100 | resume = %Resume{ 101 | server_id: guild_id, 102 | session_id: session_id, 103 | token: token 104 | } 105 | 106 | send_command(:RESUME, resume, state) 107 | 108 | {:noreply, state} 109 | end 110 | 111 | def handle_continue(:update_instance, %Session{instance: instance} = state) do 112 | Instance.update(instance, state) 113 | {:noreply, state} 114 | end 115 | 116 | def handle_cast({:send_command, operation, data}, state) do 117 | send_command(operation, data, state) 118 | {:noreply, state} 119 | end 120 | 121 | def handle_info({:gun_up, gun_pid, :http}, %Session{gun_pid: gun_pid} = state) do 122 | stream_ref = :gun.ws_upgrade(gun_pid, @query) 123 | state = %{state | stream_ref: stream_ref} 124 | {:noreply, state} 125 | end 126 | 127 | def handle_info( 128 | {:gun_upgrade, gun_pid, stream_ref, ["websocket"], _headers}, 129 | %Session{gun_pid: gun_pid, stream_ref: stream_ref} = state 130 | ) do 131 | {:noreply, state} 132 | end 133 | 134 | def handle_info( 135 | {:gun_ws, gun_pid, stream_ref, frame}, 136 | %Session{gun_pid: gun_pid, stream_ref: stream_ref} = state 137 | ) do 138 | handle_frame(frame, state) 139 | end 140 | 141 | def handle_info( 142 | {:gun_response, gun_pid, stream_ref, _is_fin, _status, _headers}, 143 | %Session{gun_pid: gun_pid, stream_ref: stream_ref} = state 144 | ) do 145 | {:noreply, state, @reconnect} 146 | end 147 | 148 | def handle_info( 149 | {:gun_error, gun_pid, stream_ref, _reason}, 150 | %Session{gun_pid: gun_pid, stream_ref: stream_ref} = state 151 | ) do 152 | {:noreply, state, @reconnect} 153 | end 154 | 155 | def handle_info( 156 | {:gun_down, gun_pid, _protocol, _reason, _killed, _unprocessed}, 157 | %Session{gun_pid: gun_pid} = state 158 | ) do 159 | {:noreply, state, @reconnect} 160 | end 161 | 162 | def handle_info(:heartbeat, %Session{heartbeat_ack: true} = state) do 163 | heartbeat_nonce = System.unique_integer() 164 | send_command(:HEARTBEAT, heartbeat_nonce, state) 165 | state = %{state | heartbeat_nonce: heartbeat_nonce, heartbeat_ack: false} 166 | {:noreply, state} 167 | end 168 | 169 | def handle_info(:heartbeat, %Session{heartbeat_ack: false} = state) do 170 | {:noreply, state, @reconnect} 171 | end 172 | 173 | defp handle_frame({:text, frame}, state) do 174 | frame 175 | |> Jason.decode!() 176 | |> Payload.cast() 177 | |> handle_payload(state) 178 | end 179 | 180 | defp handle_frame({:close, status, _reason}, state) when status in @close_session do 181 | {:stop, :killed, state} 182 | end 183 | 184 | defp handle_frame({:close, status, _reason}, state) when status in @close_dropped do 185 | {:stop, :normal, state} 186 | end 187 | 188 | defp handle_frame({:close, _status, _reason}, state) do 189 | {:noreply, state, @reconnect} 190 | end 191 | 192 | defp handle_payload(%Payload{operation: :HELLO, data: data}, state) do 193 | %Hello{heartbeat_interval: heartbeat_interval} = Hello.cast(data) 194 | 195 | heartbeat_interval = trunc(heartbeat_interval) 196 | 197 | {:ok, heartbeat_ref} = :timer.send_interval(heartbeat_interval, self(), :heartbeat) 198 | 199 | state = %{state | heartbeat_ref: heartbeat_ref, heartbeat_ack: true} 200 | {:noreply, state, @identify} 201 | end 202 | 203 | defp handle_payload(%Payload{operation: :READY, data: data}, state) do 204 | %Ready{ssrc: ssrc, ip: remote_ip, port: remote_port} = Ready.cast(data) 205 | 206 | udp_socket = Audio.get_udp_socket() 207 | 208 | {local_ip, local_port} = Audio.discover_local(udp_socket, remote_ip, remote_port, ssrc) 209 | 210 | select_protocol = %SelectProtocol{ 211 | data: %SelectProtocol.Data{ 212 | address: local_ip, 213 | port: local_port, 214 | mode: Audio.encryption_mode() 215 | } 216 | } 217 | 218 | send_command(:SELECT_PROTOCOL, select_protocol, state) 219 | 220 | state = %{ 221 | state 222 | | been_ready?: true, 223 | udp_socket: udp_socket, 224 | audio_ip: remote_ip, 225 | audio_port: remote_port, 226 | ssrc: ssrc 227 | } 228 | 229 | {:noreply, state} 230 | end 231 | 232 | defp handle_payload(%Payload{operation: :SESSION_DESCRIPTION, data: data}, state) do 233 | %SessionDescription{secret_key: secret_key} = SessionDescription.cast(data) 234 | secret_key = :erlang.list_to_binary(secret_key) 235 | 236 | state = %{state | secret_key: secret_key} 237 | {:noreply, state, @update_instance} 238 | end 239 | 240 | defp handle_payload(%Payload{operation: :RESUMED}, state) do 241 | {:noreply, state, @update_instance} 242 | end 243 | 244 | defp handle_payload( 245 | %Payload{operation: :HEARTBEAT_ACK, data: received_nonce}, 246 | %Session{heartbeat_nonce: heartbeat_nonce} = state 247 | ) do 248 | received_nonce = String.to_integer(received_nonce) 249 | heartbeat_ack = received_nonce == heartbeat_nonce 250 | state = %{state | heartbeat_ack: heartbeat_ack} 251 | {:noreply, state} 252 | end 253 | 254 | defp handle_payload(_payload, state) do 255 | {:noreply, state} 256 | end 257 | 258 | defp send_command(operation, data, %Session{gun_pid: gun_pid}) do 259 | payload = %Payload{operation: operation, data: data} 260 | command = Payload.to_command(payload) 261 | encoded = Jason.encode!(command) 262 | message = {:text, encoded} 263 | 264 | :ok = :gun.ws_send(gun_pid, message) 265 | end 266 | end 267 | -------------------------------------------------------------------------------- /lib/coxir/voice/instance.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Voice.Instance do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use GenServer, restart: :transient 6 | 7 | alias Coxir.Gateway 8 | alias Coxir.Gateway.Producer 9 | alias Coxir.Gateway.Payload.{VoiceServerUpdate, VoiceInstanceUpdate} 10 | alias Coxir.{VoiceState, Voice} 11 | alias Coxir.Voice.{Session, Audio} 12 | alias __MODULE__ 13 | 14 | @type instance :: pid 15 | 16 | @update_session {:continue, :update_session} 17 | 18 | defstruct [ 19 | :gateway, 20 | :player_module, 21 | :player, 22 | :user_id, 23 | :guild_id, 24 | :channel_id, 25 | :session_id, 26 | :endpoint_host, 27 | :endpoint_port, 28 | :token, 29 | :session, 30 | :audio 31 | ] 32 | 33 | def play(instance, player_module, playable, options) do 34 | GenServer.call(instance, {:play, player_module, playable, options}) 35 | end 36 | 37 | def pause(instance) do 38 | GenServer.call(instance, :pause) 39 | end 40 | 41 | def resume(instance) do 42 | GenServer.call(instance, :resume) 43 | end 44 | 45 | def playing?(instance) do 46 | GenServer.call(instance, :playing?) 47 | end 48 | 49 | def stop_playing(instance) do 50 | GenServer.call(instance, :stop_playing) 51 | end 52 | 53 | def has_endpoint?(instance) do 54 | GenServer.call(instance, :has_endpoint?) 55 | end 56 | 57 | def get_channel_id(instance) do 58 | GenServer.call(instance, :get_channel_id) 59 | end 60 | 61 | def update(instance, struct) do 62 | GenServer.cast(instance, {:update, struct}) 63 | end 64 | 65 | def start_link(state) do 66 | GenServer.start_link(__MODULE__, state) 67 | end 68 | 69 | def init(state) do 70 | Process.flag(:trap_exit, true) 71 | {:ok, state} 72 | end 73 | 74 | def handle_continue(:update_session, %Instance{session_id: nil} = state) do 75 | {:noreply, state} 76 | end 77 | 78 | def handle_continue(:update_session, %Instance{endpoint_host: nil} = state) do 79 | {:noreply, state} 80 | end 81 | 82 | def handle_continue(:update_session, %Instance{session: nil} = state) do 83 | %Instance{ 84 | user_id: user_id, 85 | guild_id: guild_id, 86 | session_id: session_id, 87 | endpoint_host: endpoint_host, 88 | endpoint_port: endpoint_port, 89 | token: token 90 | } = state 91 | 92 | session_state = %Session{ 93 | instance: self(), 94 | user_id: user_id, 95 | guild_id: guild_id, 96 | session_id: session_id, 97 | endpoint_host: endpoint_host, 98 | endpoint_port: endpoint_port, 99 | token: token 100 | } 101 | 102 | {:ok, session} = Session.start_link(session_state) 103 | 104 | state = %{state | session: session} 105 | {:noreply, state} 106 | end 107 | 108 | def handle_continue(:update_session, %Instance{session: session} = state) do 109 | state = %{state | session: nil, audio: nil} 110 | update_player(state) 111 | dispatch_update(state) 112 | Process.exit(session, :kill) 113 | {:noreply, state, @update_session} 114 | end 115 | 116 | def handle_call(:has_endpoint?, _from, %Instance{endpoint_host: endpoint_host} = state) do 117 | has_endpoint? = not is_nil(endpoint_host) 118 | {:reply, has_endpoint?, state} 119 | end 120 | 121 | def handle_call(:get_channel_id, _from, %Instance{channel_id: channel_id} = state) do 122 | {:reply, channel_id, state} 123 | end 124 | 125 | def handle_call( 126 | {:play, player_module, playable, options}, 127 | _from, 128 | %Instance{player: nil} = state 129 | ) do 130 | init_argument = {playable, options} 131 | child_spec = player_module.child_spec(init_argument) 132 | 133 | %{start: start_mfa} = child_spec 134 | {module, function, arguments} = start_mfa 135 | 136 | case apply(module, function, arguments) do 137 | {:ok, player} -> 138 | state = %{state | player_module: player_module, player: player} 139 | update_player(state) 140 | dispatch_update(state) 141 | {:reply, :ok, state} 142 | 143 | {:error, _reason} = result -> 144 | {:reply, result, state} 145 | end 146 | end 147 | 148 | def handle_call( 149 | {:play, _player_module, _playable, _options} = call, 150 | from, 151 | %Instance{player: player} = state 152 | ) do 153 | Process.exit(player, :kill) 154 | state = %{state | player: nil} 155 | handle_call(call, from, state) 156 | end 157 | 158 | def handle_call(:pause, _from, %Instance{player: nil} = state) do 159 | {:reply, {:error, :no_player}, state} 160 | end 161 | 162 | def handle_call(:pause, _from, %Instance{player_module: player_module, player: player} = state) do 163 | if player_module.playing?(player) do 164 | player_module.pause(player) 165 | end 166 | 167 | {:reply, :ok, state} 168 | end 169 | 170 | def handle_call(:resume, _from, %Instance{player: nil} = state) do 171 | {:reply, {:error, :no_player}, state} 172 | end 173 | 174 | def handle_call(:resume, _from, %Instance{player_module: player_module, player: player} = state) do 175 | if not player_module.playing?(player) do 176 | player_module.resume(player) 177 | end 178 | 179 | {:reply, :ok, state} 180 | end 181 | 182 | def handle_call(:playing?, _from, state) do 183 | playing? = get_playing?(state) 184 | {:reply, playing?, state} 185 | end 186 | 187 | def handle_call(:stop_playing, _from, %Instance{player: nil} = state) do 188 | {:reply, :ok, state} 189 | end 190 | 191 | def handle_call(:stop_playing, _from, %Instance{player: player} = state) do 192 | Process.exit(player, :kill) 193 | state = %{state | player: nil} 194 | {:reply, :ok, state} 195 | end 196 | 197 | def handle_cast({:update, struct}, state) do 198 | handle_update(struct, state) 199 | end 200 | 201 | def handle_info({:EXIT, session, :killed}, %Instance{session: session} = state) do 202 | state = %{state | session_id: nil, endpoint_host: nil, session: nil, audio: nil} 203 | update_player(state) 204 | dispatch_update(state) 205 | 206 | %Instance{gateway: gateway, guild_id: guild_id, channel_id: channel_id} = state 207 | Voice.update_voice_state(gateway, guild_id, channel_id) 208 | 209 | {:noreply, state} 210 | end 211 | 212 | def handle_info({:EXIT, session, :normal}, %Instance{session: session} = state) do 213 | {:noreply, state} 214 | end 215 | 216 | def handle_info({:EXIT, player, _reason}, %Instance{player: player} = state) do 217 | state = %{state | player: nil} 218 | {:noreply, state} 219 | end 220 | 221 | def handle_info({:EXIT, _process, :killed}, state) do 222 | {:noreply, state} 223 | end 224 | 225 | defp handle_update(%VoiceState{channel_id: channel_id, session_id: session_id}, state) do 226 | state = %{state | channel_id: channel_id, session_id: session_id} 227 | {:noreply, state, @update_session} 228 | end 229 | 230 | defp handle_update(%VoiceServerUpdate{endpoint: endpoint, token: token}, state) do 231 | [host, port] = String.split(endpoint, ":") 232 | endpoint_host = :binary.bin_to_list(host) 233 | endpoint_port = String.to_integer(port) 234 | 235 | state = %{state | endpoint_host: endpoint_host, endpoint_port: endpoint_port, token: token} 236 | {:noreply, state, @update_session} 237 | end 238 | 239 | defp handle_update(%Session{} = session_state, %Instance{session: session} = state) do 240 | %Session{ 241 | udp_socket: udp_socket, 242 | audio_ip: audio_ip, 243 | audio_port: audio_port, 244 | ssrc: ssrc, 245 | secret_key: secret_key 246 | } = session_state 247 | 248 | audio = %Audio{ 249 | session: session, 250 | udp_socket: udp_socket, 251 | ip: audio_ip, 252 | port: audio_port, 253 | ssrc: ssrc, 254 | secret_key: secret_key 255 | } 256 | 257 | state = %{state | audio: audio} 258 | update_player(state) 259 | dispatch_update(state) 260 | {:noreply, state} 261 | end 262 | 263 | defp dispatch_update( 264 | %Instance{ 265 | gateway: gateway, 266 | user_id: user_id, 267 | guild_id: guild_id, 268 | channel_id: channel_id, 269 | player: player, 270 | audio: audio 271 | } = state 272 | ) do 273 | voice_instance_update = %VoiceInstanceUpdate{ 274 | instance: self(), 275 | user_id: user_id, 276 | guild_id: guild_id, 277 | channel_id: channel_id, 278 | has_player?: is_nil(player), 279 | invalid?: is_nil(audio), 280 | playing?: get_playing?(state) 281 | } 282 | 283 | producer = Gateway.get_producer(gateway) 284 | Producer.notify(producer, voice_instance_update) 285 | end 286 | 287 | defp get_playing?(%Instance{player: nil}) do 288 | false 289 | end 290 | 291 | defp get_playing?(%Instance{player_module: player_module, player: player}) do 292 | player_module.playing?(player) 293 | end 294 | 295 | defp update_player(%Instance{player: nil}) do 296 | :noop 297 | end 298 | 299 | defp update_player(%Instance{player_module: player_module, player: player, audio: nil}) do 300 | player_module.invalidate(player) 301 | end 302 | 303 | defp update_player(%Instance{player_module: player_module, player: player, audio: audio}) do 304 | player_module.ready(player, audio) 305 | end 306 | end 307 | -------------------------------------------------------------------------------- /lib/coxir/gateway.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway do 2 | @moduledoc """ 3 | Supervises the components necessary to interact with Discord's gateway. 4 | 5 | ### Using the module 6 | 7 | The module can be used with `Kernel.use/2` to transform the calling module into a gateway. 8 | 9 | Check out the `__using__/1` macro to see what this does exactly. 10 | """ 11 | import Supervisor, only: [start_child: 2] 12 | import Bitwise 13 | 14 | alias Coxir.{API, Sharder, Token} 15 | alias Coxir.Gateway.Payload.{GatewayInfo, RequestGuildMembers, UpdatePresence} 16 | alias Coxir.Gateway.{Producer, Dispatcher, Consumer, Handler} 17 | alias Coxir.Gateway.{Intents, Session} 18 | alias Coxir.Model.Snowflake 19 | alias Coxir.{Guild, Channel} 20 | 21 | @default_config [ 22 | sharder: Sharder.Default, 23 | intents: :non_privileged 24 | ] 25 | 26 | @typedoc """ 27 | A gateway process. 28 | """ 29 | @type gateway :: Supervisor.supervisor() 30 | 31 | @typedoc """ 32 | The configuration that must be passed to `start_link/2`. 33 | 34 | If no `token` is provided, one is expected to be configured as `:token` under the `:coxir` app. 35 | 36 | If no `shard_count` is provided, the value suggested by Discord will be used. 37 | """ 38 | @type config :: [ 39 | token: Token.t() | none, 40 | intents: Intents.intents() | :non_privileged, 41 | shard_count: non_neg_integer | none, 42 | sharder: Sharder.t() | Sharder.Default, 43 | handler: Handler.handler() 44 | ] 45 | 46 | @doc """ 47 | Defines functions in order to transform the calling module into a gateway. 48 | 49 | Defines `child_spec/1` and `start_link/0` so that the module can be added to a supervisor. 50 | 51 | Note that the `t:gateway/0` process that the second function starts is named after the module. 52 | 53 | Defines `get_user_id/0` which delegates to `get_user_id/1`. 54 | 55 | Defines `update_presence/1` which delegates to `update_presence/2`. 56 | 57 | Defines `update_presence/2` which delegates to `update_presence/3`. 58 | 59 | Requires the `Coxir.Gateway.Handler` behaviour to be implemented for handling events. 60 | 61 | Custom configuration can be given for this module by configuring it under the `:coxir` app. 62 | """ 63 | defmacro __using__(_options) do 64 | quote location: :keep do 65 | @behaviour Coxir.Gateway.Handler 66 | 67 | alias Coxir.Gateway 68 | 69 | def child_spec(_runtime) do 70 | %{ 71 | id: __MODULE__, 72 | start: {__MODULE__, :start_link, []}, 73 | restart: :permanent 74 | } 75 | end 76 | 77 | def start_link do 78 | :coxir 79 | |> Application.get_env(__MODULE__, []) 80 | |> Keyword.put(:handler, __MODULE__) 81 | |> Gateway.start_link(name: __MODULE__) 82 | end 83 | 84 | def get_user_id do 85 | Gateway.get_user_id(__MODULE__) 86 | end 87 | 88 | def update_presence(params) do 89 | Gateway.update_presence(__MODULE__, params) 90 | end 91 | 92 | def update_presence(where, params) do 93 | Gateway.update_presence(__MODULE__, where, params) 94 | end 95 | end 96 | end 97 | 98 | @doc """ 99 | Calls `update_presence/3` on all the shards of a given gateway. 100 | """ 101 | @spec update_presence(gateway, Enum.t()) :: :ok 102 | def update_presence(gateway, params) do 103 | shard_count = get_shard_count(gateway) 104 | 105 | for index <- 1..shard_count do 106 | :ok = update_presence(gateway, index - 1, params) 107 | end 108 | 109 | :ok 110 | end 111 | 112 | @doc """ 113 | Updates the presence on a given channel, guild or specific shard. 114 | 115 | The possible parameters are the fields of `t:Coxir.Gateway.Payload.UpdatePresence.t/0`. 116 | """ 117 | @spec update_presence(gateway, Channel.t() | Guild.t() | non_neg_integer, Enum.t()) :: :ok 118 | def update_presence(gateway, where, params) do 119 | shard = get_shard(gateway, where) 120 | params = Map.new(params) 121 | payload = UpdatePresence.cast(params) 122 | Session.update_presence(shard, payload) 123 | end 124 | 125 | @doc """ 126 | Requests members for a given guild. 127 | 128 | The possible parameters are the fields of `t:Coxir.Gateway.Payload.RequestGuildMembers.t/0`. 129 | """ 130 | @spec request_guild_members(gateway, Guild.t(), Enum.t()) :: :ok 131 | def request_guild_members(gateway, %Guild{id: guild_id} = guild, params) do 132 | shard = get_shard(gateway, guild) 133 | 134 | params = 135 | params 136 | |> Map.new() 137 | |> Map.put(:guild_id, guild_id) 138 | 139 | payload = RequestGuildMembers.cast(params) 140 | Session.request_guild_members(shard, payload) 141 | end 142 | 143 | @doc """ 144 | Returns the session process for a given channel, guild or specific shard. 145 | """ 146 | @spec get_shard(gateway, Channel.t() | Guild.t() | non_neg_integer) :: Session.session() 147 | def get_shard(gateway, %Channel{guild_id: nil}) do 148 | get_shard(gateway, 0) 149 | end 150 | 151 | def get_shard(gateway, %Channel{guild_id: guild_id}) do 152 | guild = %Guild{id: guild_id} 153 | get_shard(gateway, guild) 154 | end 155 | 156 | def get_shard(gateway, %Guild{id: id}) do 157 | shard_count = get_shard_count(gateway) 158 | index = rem(id >>> 22, shard_count) 159 | get_shard(gateway, index) 160 | end 161 | 162 | def get_shard(gateway, index) when is_integer(index) do 163 | {sharder, sharder_module} = get_sharder(gateway) 164 | sharder_module.get_shard(sharder, index) 165 | end 166 | 167 | @doc """ 168 | Returns the id of the user the given gateway is running for. 169 | """ 170 | @spec get_user_id(gateway) :: Snowflake.t() 171 | def get_user_id(gateway) do 172 | %Session{user_id: user_id} = get_session_options(gateway) 173 | user_id 174 | end 175 | 176 | @doc """ 177 | Returns the token configured for the given gateway. 178 | """ 179 | @spec get_token(gateway) :: Token.t() 180 | def get_token(gateway) do 181 | %Session{token: token} = get_session_options(gateway) 182 | token 183 | end 184 | 185 | @doc """ 186 | Returns the `t:Coxir.Gateway.Producer.producer/0` process of a given gateway. 187 | """ 188 | @spec get_producer(gateway) :: Producer.producer() 189 | def get_producer(gateway) do 190 | children = Supervisor.which_children(gateway) 191 | 192 | Enum.find_value( 193 | children, 194 | fn {_id, pid, _type, [module]} -> 195 | if module == Producer, do: pid 196 | end 197 | ) 198 | end 199 | 200 | @doc """ 201 | Starts a gateway with the given configuration and options. 202 | """ 203 | @spec start_link(config, list(Supervisor.option() | Supervisor.init_option())) :: 204 | Supervisor.on_start() 205 | def start_link(config, options \\ []) do 206 | handler = Keyword.fetch!(config, :handler) 207 | options = [{:strategy, :rest_for_one} | options] 208 | 209 | with {:ok, gateway} <- Supervisor.start_link([], options) do 210 | {:ok, producer} = start_child(gateway, Producer) 211 | 212 | {:ok, dispatcher} = start_child(gateway, {Dispatcher, producer}) 213 | 214 | consumer_options = %Consumer{handler: handler, dispatcher: dispatcher} 215 | {:ok, _consumer} = start_child(gateway, {Consumer, consumer_options}) 216 | 217 | sharder_spec = generate_sharder_spec(producer, config) 218 | {:ok, _sharder} = start_child(gateway, sharder_spec) 219 | 220 | {:ok, gateway} 221 | end 222 | end 223 | 224 | defp get_sharder(gateway) do 225 | children = Supervisor.which_children(gateway) 226 | 227 | Enum.find_value( 228 | children, 229 | fn {id, pid, _type, [module]} -> 230 | if id == :sharder, do: {pid, module} 231 | end 232 | ) 233 | end 234 | 235 | defp get_shard_count(gateway) do 236 | %Sharder{shard_count: shard_count} = get_sharder_options(gateway) 237 | shard_count 238 | end 239 | 240 | defp get_session_options(gateway) do 241 | %Sharder{session_options: session_options} = get_sharder_options(gateway) 242 | session_options 243 | end 244 | 245 | defp get_sharder_options(gateway) do 246 | {:ok, spec} = :supervisor.get_childspec(gateway, :sharder) 247 | %{start: {_module, _function, [sharder_options | _rest]}} = spec 248 | sharder_options 249 | end 250 | 251 | defp generate_sharder_spec(producer, config) do 252 | global = Application.get_all_env(:coxir) 253 | 254 | config = 255 | @default_config 256 | |> Keyword.merge(global) 257 | |> Keyword.merge(config) 258 | 259 | token = Token.from_options!(config) 260 | 261 | intents = 262 | config 263 | |> Keyword.fetch!(:intents) 264 | |> Intents.get_value() 265 | 266 | {gateway_host, shard_count, _start_limit} = request_gateway_info(token) 267 | 268 | session_options = %Session{ 269 | user_id: Token.get_user_id(token), 270 | token: token, 271 | intents: intents, 272 | producer: producer, 273 | gateway_host: gateway_host 274 | } 275 | 276 | sharder_options = %Sharder{ 277 | shard_count: Keyword.get(config, :shard_count, shard_count), 278 | session_options: session_options 279 | } 280 | 281 | sharder_module = Keyword.fetch!(config, :sharder) 282 | 283 | spec = sharder_module.child_spec(sharder_options) 284 | 285 | %{spec | id: :sharder} 286 | end 287 | 288 | defp request_gateway_info(token) do 289 | {:ok, object} = API.get("gateway/bot", token: token) 290 | gateway_info = GatewayInfo.cast(object) 291 | 292 | %GatewayInfo{ 293 | url: "wss://" <> gateway_host, 294 | shards: shard_count, 295 | session_start_limit: start_limit 296 | } = gateway_info 297 | 298 | gateway_host = :binary.bin_to_list(gateway_host) 299 | 300 | {gateway_host, shard_count, start_limit} 301 | end 302 | end 303 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /lib/coxir/gateway/stage/dispatcher.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Gateway.Dispatcher do 2 | @moduledoc """ 3 | Work in progress. 4 | """ 5 | use GenStage 6 | 7 | alias Coxir.Gateway.Payload 8 | alias Coxir.Gateway.Payload.{Ready, GuildMembersChunk} 9 | alias Coxir.Gateway.Payload.{MessageReactionRemoveAll, MessageReactionRemoveEmoji} 10 | alias Coxir.Gateway.Payload.{VoiceServerUpdate, VoiceInstanceUpdate} 11 | 12 | alias Coxir.Model.Loader 13 | alias Coxir.{User, Channel, Guild} 14 | alias Coxir.{Message, Reaction, Interaction} 15 | alias Coxir.{Role, Member, Presence, VoiceState} 16 | alias Coxir.Voice 17 | 18 | @type event :: 19 | ready 20 | | resumed 21 | | channel_create 22 | | channel_update 23 | | channel_delete 24 | | thread_create 25 | | thread_update 26 | | thread_delete 27 | | guild_create 28 | | guild_update 29 | | guild_delete 30 | | guild_emojis_update 31 | | guild_member_add 32 | | guild_member_update 33 | | guild_member_remove 34 | | guild_members_chunk 35 | | guild_role_create 36 | | guild_role_update 37 | | guild_role_delete 38 | | interaction_create 39 | | message_create 40 | | message_update 41 | | message_delete 42 | | message_delete_bulk 43 | | message_reaction_add 44 | | message_reaction_remove 45 | | message_reaction_remove_all 46 | | message_reaction_remove_emoji 47 | | presence_update 48 | | user_update 49 | | voice_state_update 50 | | voice_server_update 51 | | voice_instance_update 52 | | payload 53 | 54 | @type ready :: {:READY, Ready.t()} 55 | 56 | @type resumed :: :RESUMED 57 | 58 | @type channel_create :: {:CHANNEL_CREATE, Channel.t()} 59 | 60 | @type channel_update :: {:CHANNEL_UPDATE, Channel.t()} 61 | 62 | @type channel_delete :: {:CHANNEL_DELETE, Channel.t()} 63 | 64 | @type thread_create :: {:THREAD_CREATE, Channel.t()} 65 | 66 | @type thread_update :: {:THREAD_UPDATE, Channel.t()} 67 | 68 | @type thread_delete :: {:THREAD_DELETE, Channel.t()} 69 | 70 | @type guild_create :: {:GUILD_CREATE, Guild.t()} 71 | 72 | @type guild_update :: {:GUILD_UPDATE, Guild.t()} 73 | 74 | @type guild_delete :: {:GUILD_DELETE, Guild.t()} 75 | 76 | @type guild_emojis_update :: {:GUILD_EMOJIS_UPDATE, Guild.t()} 77 | 78 | @type guild_member_add :: {:GUILD_MEMBER_ADD, Member.t()} 79 | 80 | @type guild_member_update :: {:GUILD_MEMBER_UPDATE, Member.t()} 81 | 82 | @type guild_member_remove :: {:GUILD_MEMBER_REMOVE, Member.t()} 83 | 84 | @type guild_members_chunk :: {:GUILD_MEMBERS_CHUNK, GuildMembersChunk.t()} 85 | 86 | @type guild_role_create :: {:GUILD_ROLE_CREATE, Role.t()} 87 | 88 | @type guild_role_update :: {:GUILD_ROLE_UPDATE, Role.t()} 89 | 90 | @type guild_role_delete :: {:GUILD_ROLE_DELETE, Role.t()} 91 | 92 | @type interaction_create :: {:INTERACTION_CREATE, Interaction.t()} 93 | 94 | @type message_create :: {:MESSAGE_CREATE, Message.t()} 95 | 96 | @type message_update :: {:MESSAGE_UPDATE, Message.t()} 97 | 98 | @type message_delete :: {:MESSAGE_DELETE, Message.t()} 99 | 100 | @type message_delete_bulk :: {:MESSAGE_DELETE_BULK, list(Message.t())} 101 | 102 | @type message_reaction_add :: {:MESSAGE_REACTION_ADD, Reaction.t()} 103 | 104 | @type message_reaction_remove :: {:MESSAGE_REACTION_REMOVE, Reaction.t()} 105 | 106 | @type message_reaction_remove_all :: 107 | {:MESSAGE_REACTION_REMOVE_ALL, MessageReactionRemoveAll.t()} 108 | 109 | @type message_reaction_remove_emoji :: 110 | {:MESSAGE_REACTION_REMOVE_EMOJI, MessageReactionRemoveEmoji.t()} 111 | 112 | @type presence_update :: {:PRESENCE_UPDATE, Presence.t()} 113 | 114 | @type user_update :: {:USER_UPDATE, User.t()} 115 | 116 | @type voice_state_update :: {:VOICE_STATE_UPDATE, VoiceState.t()} 117 | 118 | @type voice_server_update :: {:VOICE_SERVER_UPDATE, VoiceServerUpdate.t()} 119 | 120 | @type voice_instance_update :: {:VOICE_INSTANCE_UPDATE, VoiceInstanceUpdate.t()} 121 | 122 | @type payload :: {:PAYLOAD, Payload.t()} 123 | 124 | def start_link(producer) do 125 | GenStage.start_link(__MODULE__, producer) 126 | end 127 | 128 | def init(producer) do 129 | {:producer_consumer, nil, subscribe_to: [producer]} 130 | end 131 | 132 | def handle_events(payloads, _from, state) do 133 | events = Enum.map(payloads, &handle_event/1) 134 | {:noreply, events, state} 135 | end 136 | 137 | defp handle_event(%Payload{event: "READY", data: object}) do 138 | ready = Ready.cast(object) 139 | {:READY, ready} 140 | end 141 | 142 | defp handle_event(%Payload{event: "RESUMED"}) do 143 | :RESUMED 144 | end 145 | 146 | defp handle_event(%Payload{event: "CHANNEL_CREATE", data: object}) do 147 | channel = Loader.load(Channel, object) 148 | {:CHANNEL_CREATE, channel} 149 | end 150 | 151 | defp handle_event(%Payload{event: "CHANNEL_UPDATE", data: object}) do 152 | channel = Loader.load(Channel, object) 153 | {:CHANNEL_UPDATE, channel} 154 | end 155 | 156 | defp handle_event(%Payload{event: "CHANNEL_DELETE", data: object}) do 157 | channel = Loader.load(Channel, object) 158 | Loader.unload(channel) 159 | {:CHANNEL_DELETE, channel} 160 | end 161 | 162 | defp handle_event(%Payload{event: "THREAD_CREATE", data: object}) do 163 | channel = Loader.load(Channel, object) 164 | {:THREAD_CREATE, channel} 165 | end 166 | 167 | defp handle_event(%Payload{event: "THREAD_UPDATE", data: object}) do 168 | channel = Loader.load(Channel, object) 169 | {:THREAD_UPDATE, channel} 170 | end 171 | 172 | defp handle_event(%Payload{event: "THREAD_DELETE", data: object}) do 173 | channel = Loader.load(Channel, object) 174 | Loader.unload(channel) 175 | {:THREAD_DELETE, channel} 176 | end 177 | 178 | defp handle_event(%Payload{event: "GUILD_CREATE", data: object} = payload) do 179 | %Guild{voice_states: voice_states} = guild = Loader.load(Guild, object) 180 | 181 | Enum.each(voice_states, &handle_voice(&1, payload)) 182 | 183 | {:GUILD_CREATE, guild} 184 | end 185 | 186 | defp handle_event(%Payload{event: "GUILD_UPDATE", data: object}) do 187 | guild = Loader.load(Guild, object) 188 | {:GUILD_UPDATE, guild} 189 | end 190 | 191 | defp handle_event(%Payload{event: "GUILD_DELETE", data: object}) do 192 | guild = Loader.load(Guild, object) 193 | 194 | if not Map.has_key?(object, "unavailable") do 195 | Loader.unload(guild) 196 | end 197 | 198 | {:GUILD_DELETE, guild} 199 | end 200 | 201 | defp handle_event(%Payload{event: "GUILD_EMOJIS_UPDATE", data: data}) do 202 | %{"guild_id" => guild_id, "emojis" => emojis} = data 203 | object = %{"id" => guild_id, "emojis" => emojis} 204 | 205 | guild = Loader.load(Guild, object) 206 | {:GUILD_EMOJIS_UPDATE, guild} 207 | end 208 | 209 | defp handle_event(%Payload{event: "GUILD_MEMBER_ADD", data: object}) do 210 | member = Loader.load(Member, object) 211 | {:GUILD_MEMBER_ADD, member} 212 | end 213 | 214 | defp handle_event(%Payload{event: "GUILD_MEMBER_UPDATE", data: object}) do 215 | member = Loader.load(Member, object) 216 | {:GUILD_MEMBER_UPDATE, member} 217 | end 218 | 219 | defp handle_event(%Payload{event: "GUILD_MEMBER_REMOVE", data: object}) do 220 | member = Loader.load(Member, object) 221 | Loader.unload(member) 222 | {:GUILD_MEMBER_REMOVE, member} 223 | end 224 | 225 | defp handle_event(%Payload{event: "GUILD_MEMBERS_CHUNK", data: object}) do 226 | guild_members_chunk = GuildMembersChunk.cast(object) 227 | {:GUILD_MEMBERS_CHUNK, guild_members_chunk} 228 | end 229 | 230 | defp handle_event(%Payload{event: "GUILD_ROLE_CREATE", data: data}) do 231 | %{"guild_id" => guild_id, "role" => object} = data 232 | object = Map.put(object, "guild_id", guild_id) 233 | 234 | role = Loader.load(Role, object) 235 | {:GUILD_ROLE_CREATE, role} 236 | end 237 | 238 | defp handle_event(%Payload{event: "GUILD_ROLE_UPDATE", data: data}) do 239 | %{"guild_id" => guild_id, "role" => object} = data 240 | object = Map.put(object, "guild_id", guild_id) 241 | 242 | role = Loader.load(Role, object) 243 | {:GUILD_ROLE_UPDATE, role} 244 | end 245 | 246 | defp handle_event(%Payload{event: "GUILD_ROLE_DELETE", data: data}) do 247 | %{"guild_id" => guild_id, "role_id" => role_id} = data 248 | object = %{"id" => role_id, "guild_id" => guild_id} 249 | 250 | role = Loader.load(Role, object) 251 | Loader.unload(role) 252 | {:GUILD_ROLE_DELETE, role} 253 | end 254 | 255 | defp handle_event(%Payload{event: "INTERACTION_CREATE", data: object}) do 256 | interaction = Loader.load(Interaction, object) 257 | {:INTERACTION_CREATE, interaction} 258 | end 259 | 260 | defp handle_event(%Payload{event: "MESSAGE_CREATE", data: object}) do 261 | message = Loader.load(Message, object) 262 | {:MESSAGE_CREATE, message} 263 | end 264 | 265 | defp handle_event(%Payload{event: "MESSAGE_UPDATE", data: object}) do 266 | message = Loader.load(Message, object) 267 | {:MESSAGE_UPDATE, message} 268 | end 269 | 270 | defp handle_event(%Payload{event: "MESSAGE_DELETE", data: object}) do 271 | message = Loader.load(Message, object) 272 | Loader.unload(message) 273 | {:MESSAGE_DELETE, message} 274 | end 275 | 276 | defp handle_event(%Payload{event: "MESSAGE_DELETE_BULK", data: data}) do 277 | %{"ids" => ids, "channel_id" => channel_id} = data 278 | 279 | mapper = fn id -> 280 | object = %{"id" => id, "channel_id" => channel_id} 281 | 282 | payload = %Payload{event: "MESSAGE_DELETE", data: object} 283 | 284 | {_name, message} = handle_event(payload) 285 | 286 | message 287 | end 288 | 289 | messages = Enum.map(ids, mapper) 290 | 291 | {:MESSAGE_DELETE_BULK, messages} 292 | end 293 | 294 | defp handle_event(%Payload{event: "MESSAGE_REACTION_ADD", data: object}) do 295 | reaction = Loader.load(Reaction, object) 296 | {:MESSAGE_REACTION_ADD, reaction} 297 | end 298 | 299 | defp handle_event(%Payload{event: "MESSAGE_REACTION_REMOVE", data: object}) do 300 | reaction = Loader.load(Reaction, object) 301 | {:MESSAGE_REACTION_REMOVE, reaction} 302 | end 303 | 304 | defp handle_event(%Payload{event: "MESSAGE_REACTION_REMOVE_ALL", data: object}) do 305 | message_reaction_remove_all = MessageReactionRemoveAll.cast(object) 306 | {:MESSAGE_REACTION_REMOVE_ALL, message_reaction_remove_all} 307 | end 308 | 309 | defp handle_event(%Payload{event: "MESSAGE_REACTION_REMOVE_EMOJI", data: object}) do 310 | message_reaction_remove_emoji = MessageReactionRemoveEmoji.cast(object) 311 | {:MESSAGE_REACTION_REMOVE_EMOJI, message_reaction_remove_emoji} 312 | end 313 | 314 | defp handle_event(%Payload{event: "PRESENCE_UPDATE", data: object}) do 315 | presence = Loader.load(Presence, object) 316 | {:PRESENCE_UPDATE, presence} 317 | end 318 | 319 | defp handle_event(%Payload{event: "USER_UPDATE", data: object}) do 320 | user = Loader.load(User, object) 321 | {:USER_UPDATE, user} 322 | end 323 | 324 | defp handle_event(%Payload{event: "VOICE_STATE_UPDATE", data: object} = payload) do 325 | voice_state = Loader.load(VoiceState, object) 326 | 327 | if is_nil(voice_state.channel_id) do 328 | Loader.unload(voice_state) 329 | end 330 | 331 | handle_voice(voice_state, payload) 332 | 333 | {:VOICE_STATE_UPDATE, voice_state} 334 | end 335 | 336 | defp handle_event(%Payload{event: "VOICE_SERVER_UPDATE", data: object} = payload) do 337 | voice_server_update = VoiceServerUpdate.cast(object) 338 | 339 | handle_voice(voice_server_update, payload) 340 | 341 | {:VOICE_SERVER_UPDATE, voice_server_update} 342 | end 343 | 344 | defp handle_event(%VoiceInstanceUpdate{} = voice_instance_update) do 345 | {:VOICE_INSTANCE_UPDATE, voice_instance_update} 346 | end 347 | 348 | defp handle_event(%Payload{} = payload) do 349 | {:PAYLOAD, payload} 350 | end 351 | 352 | defp handle_voice( 353 | %VoiceState{user_id: user_id, guild_id: guild_id} = voice_state, 354 | %Payload{user_id: user_id} 355 | ) do 356 | Voice.update(user_id, guild_id, voice_state) 357 | end 358 | 359 | defp handle_voice( 360 | %VoiceServerUpdate{guild_id: guild_id} = voice_server_update, 361 | %Payload{user_id: user_id} 362 | ) do 363 | Voice.update(user_id, guild_id, voice_server_update) 364 | end 365 | 366 | defp handle_voice(_struct, _payload) do 367 | :noop 368 | end 369 | end 370 | -------------------------------------------------------------------------------- /lib/coxir/model/loader.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Model.Loader do 2 | @moduledoc """ 3 | Implements functionality for `t:Coxir.Model.model/0` implementations. 4 | """ 5 | import Ecto 6 | import Ecto.Changeset 7 | import Coxir.Model.Helper 8 | 9 | alias Ecto.Association.{NotLoaded, BelongsTo, Has} 10 | alias Coxir.{Model, Storage, API} 11 | alias Coxir.API.Error 12 | alias Coxir.API 13 | 14 | @default_options %{ 15 | force: false, 16 | storage: true, 17 | fetch: true 18 | } 19 | 20 | @typedoc """ 21 | The options specific to this module. 22 | 23 | The `force` option specifies whether an association must be reloaded. 24 | 25 | The `storage` option specifies whether the storage should be hit. 26 | 27 | The `fetch` option specifies whether the API should be hit. 28 | """ 29 | @type loader_options :: [ 30 | force: boolean | false, 31 | storage: boolean | true, 32 | fetch: boolean | true 33 | ] 34 | 35 | @typedoc """ 36 | The options that can be passed to the functions. 37 | """ 38 | @type options :: loader_options | API.options() 39 | 40 | @typedoc """ 41 | The format of the `preloads` argument of `preload/3`. 42 | """ 43 | @type preloads :: atom | list(atom) | [{atom, preloads}] 44 | 45 | @typedoc """ 46 | The possible outcomes of a write operation. 47 | """ 48 | @type result :: {:ok, Model.instance()} | API.result() 49 | 50 | @doc """ 51 | Loads `t:Coxir.Model.instance/0` from a `t:map/0` object. 52 | 53 | This function also stores the models configured as storable. 54 | """ 55 | @spec load(Model.model(), list(map)) :: list(Model.instance()) 56 | @spec load(Model.model(), map) :: Model.instance() 57 | def load(model, objects) when is_list(objects) do 58 | Enum.map(objects, &load(model, &1)) 59 | end 60 | 61 | def load(model, %model{} = struct) do 62 | struct 63 | end 64 | 65 | def load(model, object) do 66 | model 67 | |> struct() 68 | |> loader(object) 69 | end 70 | 71 | @doc """ 72 | Removes a `t:Coxir.Model.instance/0` from the storage when of a storable model. 73 | """ 74 | @spec unload(Model.instance()) :: :ok 75 | def unload(%model{} = struct) do 76 | if storable?(model) do 77 | key = get_key(struct) 78 | Storage.delete(model, key) 79 | else 80 | :ok 81 | end 82 | end 83 | 84 | @doc """ 85 | Attempts to get a `t:Coxir.Model.instance/0` from the storage and/or the API. 86 | 87 | If fetched from the API, the instance will be passed through `load/2` on success. 88 | """ 89 | @spec get(Model.model(), Model.key(), options) :: Model.instance() | Error.t() 90 | def get(model, key, options) do 91 | options = Enum.into(options, @default_options) 92 | getter(model, key, options) 93 | end 94 | 95 | @doc """ 96 | Same as `get/3` but raises on error. 97 | """ 98 | @spec get!(Model.model(), Model.key(), options) :: Model.instance() 99 | def get!(model, key, options) do 100 | with %Error{} = error <- get(model, key, options) do 101 | raise(error) 102 | end 103 | end 104 | 105 | @doc """ 106 | Attempts to load the associations for a `t:Coxir.Model.instance/0`. 107 | 108 | If fetched from the API, the associations will be passed through `load/2` on success. 109 | """ 110 | @spec preload(list(Model.instance()), preloads, options) :: list(Model.instance()) 111 | @spec preload(Model.instance(), preloads, options) :: Model.instance() 112 | def preload(structs, preloads, options) when is_list(structs) do 113 | Enum.map( 114 | structs, 115 | fn %model{} = struct -> 116 | model.preload(struct, preloads, options) 117 | end 118 | ) 119 | end 120 | 121 | def preload(%model{} = struct, associations, options) when is_list(associations) do 122 | Enum.reduce( 123 | associations, 124 | struct, 125 | fn association, struct -> 126 | model.preload(struct, association, options) 127 | end 128 | ) 129 | end 130 | 131 | def preload(%model{} = struct, {association, nested}, options) do 132 | updater = fn value -> 133 | with %model{} = struct <- value do 134 | model.preload(struct, nested, options) 135 | end 136 | end 137 | 138 | struct 139 | |> model.preload(association, options) 140 | |> Map.update!(association, updater) 141 | end 142 | 143 | def preload(%model{} = struct, association, options) when is_atom(association) do 144 | reflection = get_association(model, association) 145 | options = Enum.into(options, @default_options) 146 | preloader(reflection, struct, options) 147 | end 148 | 149 | @doc """ 150 | Same as `preload/3` but raises on error. 151 | """ 152 | @spec preload!(list(Model.instance()), preloads, options) :: list(Model.instance()) 153 | @spec preload!(Model.instance(), preloads, options) :: Model.instance() 154 | def preload!(structs, preloads, options) when is_list(structs) do 155 | Enum.map( 156 | structs, 157 | fn struct -> 158 | preload!(struct, preloads, options) 159 | end 160 | ) 161 | end 162 | 163 | def preload!(%_model{} = struct, associations, options) when is_list(associations) do 164 | Enum.reduce( 165 | associations, 166 | struct, 167 | fn association, struct -> 168 | preload!(struct, association, options) 169 | end 170 | ) 171 | end 172 | 173 | def preload!(%_model{} = struct, {association, nested}, options) do 174 | updater = fn value -> 175 | with %_model{} = struct <- value do 176 | preload!(struct, nested, options) 177 | end 178 | end 179 | 180 | struct 181 | |> preload!(association, options) 182 | |> Map.update!(association, updater) 183 | end 184 | 185 | def preload!(%model{} = struct, association, options) do 186 | with %{^association => %Error{} = error} <- model.preload(struct, association, options) do 187 | raise(error) 188 | end 189 | end 190 | 191 | @doc """ 192 | Attempts to create a `t:Coxir.Model.instance/0` through the API. 193 | 194 | The created entity will be passed through `load/2` on success. 195 | """ 196 | @spec create(Model.model(), Enum.t(), options) :: result 197 | def create(model, params, options) do 198 | params = Map.new(params) 199 | 200 | with {:ok, object} <- model.insert(params, options) do 201 | struct = load(model, object) 202 | {:ok, struct} 203 | end 204 | end 205 | 206 | @doc """ 207 | Attempts to update a `t:Coxir.Model.instance/0` through the API. 208 | 209 | The updated entity will be passed through `load/2` on success. 210 | """ 211 | @spec update(Model.instance(), Enum.t(), options) :: result 212 | def update(%model{} = struct, params, options) do 213 | key = get_key(struct) 214 | params = Map.new(params) 215 | 216 | case model.patch(key, params, options) do 217 | :ok -> 218 | struct = loader(struct, params) 219 | {:ok, struct} 220 | 221 | {:ok, object} -> 222 | struct = load(model, object) 223 | {:ok, struct} 224 | 225 | other -> 226 | other 227 | end 228 | end 229 | 230 | @doc """ 231 | Attempts to delete a `t:Coxir.Model.instance/0` through the API. 232 | 233 | The deleted entity will be passed through `unload/1` on success. 234 | """ 235 | @spec delete(Model.instance(), options) :: result 236 | def delete(%model{} = struct, options) do 237 | key = get_key(struct) 238 | 239 | result = 240 | case model.drop(key, options) do 241 | :ok -> 242 | {:ok, struct} 243 | 244 | {:ok, object} -> 245 | struct = load(model, object) 246 | {:ok, struct} 247 | 248 | other -> 249 | other 250 | end 251 | 252 | with {:ok, struct} <- result do 253 | unload(struct) 254 | {:ok, struct} 255 | end 256 | end 257 | 258 | defp loader(%model{} = struct, object) do 259 | fields = get_fields(model) 260 | embeds = get_embeds(model) 261 | associations = get_associations(model) 262 | 263 | changeset = 264 | struct 265 | |> change() 266 | |> Map.put(:empty_values, []) 267 | 268 | casted = 269 | changeset 270 | |> cast(object, fields -- embeds) 271 | |> apply_changes() 272 | 273 | loaded = 274 | casted 275 | |> cast(object, []) 276 | |> embedder(embeds) 277 | |> associator(associations) 278 | |> apply_changes() 279 | 280 | if storable?(model) do 281 | Storage.put(loaded) 282 | else 283 | loaded 284 | end 285 | end 286 | 287 | defp embedder(%{params: params} = changeset, [embed | embeds]) do 288 | param = to_string(embed) 289 | 290 | changeset = 291 | if Map.has_key?(params, param) do 292 | caster = fn struct, object -> 293 | struct 294 | |> loader(object) 295 | |> change() 296 | end 297 | 298 | cast_embed(changeset, embed, with: caster) 299 | else 300 | changeset 301 | end 302 | 303 | embedder(changeset, embeds) 304 | end 305 | 306 | defp embedder(changeset, []) do 307 | changeset 308 | end 309 | 310 | defp associator(%{data: struct, params: params} = changeset, [association | associations]) do 311 | param = to_string(association) 312 | 313 | changeset = 314 | if Map.has_key?(params, param) do 315 | struct = void_association(struct, association) 316 | changeset = %{changeset | data: struct} 317 | 318 | assoc = build_assoc(struct, association) 319 | 320 | caster = fn _struct, object -> 321 | assoc 322 | |> loader(object) 323 | |> change() 324 | end 325 | 326 | cast_assoc(changeset, association, with: caster) 327 | else 328 | changeset 329 | end 330 | 331 | associator(changeset, associations) 332 | end 333 | 334 | defp associator(changeset, []) do 335 | changeset 336 | end 337 | 338 | defp void_association(%model{} = struct, name) do 339 | value = 340 | case get_association(model, name) do 341 | %{cardinality: :one} -> nil 342 | %{cardinality: :many} -> [] 343 | end 344 | 345 | Map.put(struct, name, value) 346 | end 347 | 348 | defp getter(model, key, %{storage: true} = options) do 349 | storage = 350 | if storable?(model) do 351 | Storage.get(model, key) 352 | end 353 | 354 | with nil <- storage do 355 | options = %{options | storage: false} 356 | getter(model, key, options) 357 | end 358 | end 359 | 360 | defp getter(model, key, %{fetch: true} = options) do 361 | if function_exported?(model, :fetch, 2) do 362 | options = Keyword.new(options) 363 | 364 | case model.fetch(key, options) do 365 | {:ok, object} -> 366 | load(model, object) 367 | 368 | {:error, error} -> 369 | error 370 | end 371 | end 372 | end 373 | 374 | defp getter(_model, _key, _options) do 375 | nil 376 | end 377 | 378 | defp preloader(%type{}, _struct, _options) when type not in [Has, BelongsTo] do 379 | raise "#{type} associations cannot be preloaded." 380 | end 381 | 382 | defp preloader(%{field: field} = reflection, struct, %{force: false} = options) do 383 | case Map.fetch!(struct, field) do 384 | %NotLoaded{} -> 385 | options = %{options | force: true} 386 | preloader(reflection, struct, options) 387 | 388 | _other -> 389 | struct 390 | end 391 | end 392 | 393 | defp preloader(%{cardinality: :one} = reflection, struct, options) do 394 | %{owner_key: owner_key, related: related, field: field} = reflection 395 | 396 | owner_value = Map.fetch!(struct, owner_key) 397 | 398 | resolved = 399 | if not is_nil(owner_value) do 400 | get(related, owner_value, options) 401 | end 402 | 403 | %{struct | field => resolved} 404 | end 405 | 406 | defp preloader(%{cardinality: :many} = reflection, %model{} = struct, options) do 407 | %{owner_key: owner_key, related_key: related_key, related: related, field: field} = reflection 408 | %{storage: storage?, fetch: fetch?} = options 409 | 410 | owner_value = Map.fetch!(struct, owner_key) 411 | 412 | storage = 413 | if storage? and storable?(related) do 414 | clauses = [{related_key, owner_value}] 415 | Storage.all_by(related, clauses) 416 | end 417 | 418 | fetch = 419 | if is_nil(storage) and fetch? do 420 | options = Keyword.new(options) 421 | 422 | case model.fetch_many(owner_value, field, options) do 423 | {:ok, objects} -> 424 | load(related, objects) 425 | 426 | {:error, error} -> 427 | error 428 | end 429 | end 430 | 431 | resolved = storage || fetch || [] 432 | 433 | %{struct | field => resolved} 434 | end 435 | end 436 | -------------------------------------------------------------------------------- /lib/coxir/model/entities/channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Coxir.Channel do 2 | @moduledoc """ 3 | Represents a Discord channel. 4 | """ 5 | use Coxir.Model 6 | 7 | @typedoc """ 8 | The struct for a channel. 9 | """ 10 | @type t :: %Channel{ 11 | id: id, 12 | type: type, 13 | position: position, 14 | name: name, 15 | topic: topic, 16 | nsfw: nsfw, 17 | bitrate: bitrate, 18 | user_limit: user_limit, 19 | rate_limit_per_user: rate_limit_per_user, 20 | icon: icon, 21 | application_id: application_id, 22 | rtc_region: rtc_region, 23 | video_quality_mode: video_quality_mode, 24 | pinned_messages: pinned_messages, 25 | recipients: recipients, 26 | invites: invites, 27 | permission_overwrites: permission_overwrites, 28 | webhooks: webhooks, 29 | voice_states: voice_states, 30 | guild: guild, 31 | guild_id: guild_id, 32 | owner: owner, 33 | owner_id: owner_id, 34 | parent: parent, 35 | parent_id: parent_id 36 | } 37 | 38 | @typedoc """ 39 | The coxir key of a channel. 40 | """ 41 | @type key :: id 42 | 43 | @typedoc """ 44 | The id of the channel. 45 | """ 46 | @type id :: Snowflake.t() 47 | 48 | @typedoc """ 49 | The type of the channel. For a list of types have a look [here](https://discord.com/developers/docs/resources/channel#channel-object-channel-types). 50 | """ 51 | @type type :: non_neg_integer | nil 52 | 53 | @typedoc """ 54 | Sorting position of the channel. 55 | """ 56 | @type position :: non_neg_integer | nil 57 | 58 | @typedoc """ 59 | The name of the channel. 60 | """ 61 | @type name :: String.t() | nil 62 | 63 | @typedoc """ 64 | The topic of the channel. 65 | """ 66 | @type topic :: String.t() | nil 67 | 68 | @typedoc """ 69 | Whether the channel is nsfw. 70 | """ 71 | @type nsfw :: boolean | nil 72 | 73 | @typedoc """ 74 | The bitrate (in bits) of the voice channel. 75 | """ 76 | @type bitrate :: non_neg_integer | nil 77 | 78 | @typedoc """ 79 | The user limit of the voice channel. 80 | """ 81 | @type user_limit :: non_neg_integer | nil 82 | 83 | @typedoc """ 84 | Amount of seconds a user has to wait before sending another message. 85 | """ 86 | @type rate_limit_per_user :: non_neg_integer | nil 87 | 88 | @typedoc """ 89 | The icon hash of the channel. 90 | """ 91 | @type icon :: String.t() | nil 92 | 93 | @typedoc """ 94 | Application id of the group DM creator if it is bot-created. 95 | """ 96 | @type application_id :: Snowflake.t() | nil 97 | 98 | @typedoc """ 99 | The voice region id for the voice channel, nil when automatic. 100 | """ 101 | @type rtc_region :: String.t() | nil 102 | 103 | @typedoc """ 104 | The camera video quality mode of the voice channel. 105 | """ 106 | @type video_quality_mode :: non_neg_integer | nil 107 | 108 | @typedoc """ 109 | The pinned messages in the channel. 110 | 111 | Needs to be preloaded via `preload/3`. 112 | """ 113 | @type pinned_messages :: list(Message.t()) | Error.t() 114 | 115 | @typedoc """ 116 | The recipients of the DM. 117 | 118 | Needs to be preloaded via `preload/3`. 119 | """ 120 | @type recipients :: list(User.t()) | nil 121 | 122 | @typedoc """ 123 | Invites for the channel. 124 | 125 | Needs to be preloaded via `preload/3`. 126 | """ 127 | @type invites :: NotLoaded.t() | list(Invite.t()) | Error.t() 128 | 129 | @typedoc """ 130 | Permission overwrites for members and roles. 131 | 132 | Needs to be preloaded via `preload/3`. 133 | """ 134 | @type permission_overwrites :: NotLoaded.t() | list(Overwrite.t()) | Error.t() 135 | 136 | @typedoc """ 137 | Webhooks configured for the channel. 138 | 139 | Needs to be preloaded via `preload/3`. 140 | """ 141 | @type webhooks :: NotLoaded.t() | list(Webhook.t()) | Error.t() 142 | 143 | @typedoc """ 144 | Active voice states for the voice channel. 145 | 146 | Needs to be preloaded via `preload/3`. 147 | """ 148 | @type voice_states :: NotLoaded.t() | list(VoiceState.t()) 149 | 150 | @typedoc """ 151 | The id of the guild the channel belongs to. 152 | """ 153 | @type guild_id :: Snowflake.t() | nil 154 | 155 | @typedoc """ 156 | The guild the channel belongs to. 157 | 158 | Needs to be preloaded via `preload/3`. 159 | """ 160 | @type guild :: NotLoaded.t() | Guild.t() | nil | Error.t() 161 | 162 | @typedoc """ 163 | The id of the creator of the group DM or thread. 164 | """ 165 | @type owner_id :: Snowflake.t() | nil 166 | 167 | @typedoc """ 168 | The creator of the group DM or thread. 169 | 170 | Needs to be preloaded via `preload/3`. 171 | """ 172 | @type owner :: NotLoaded.t() | User.t() | nil | Error.t() 173 | 174 | @typedoc """ 175 | The id of the parent category for guild channels. The id of the belonging channel for threads. 176 | """ 177 | @type parent_id :: Snowflake.t() | nil 178 | 179 | @typedoc """ 180 | The parent category for guild channels. The belonging channel for threads. 181 | 182 | Needs to be preloaded via `preload/3`. 183 | """ 184 | @type parent :: NotLoaded.t() | t | nil | Error.t() 185 | 186 | @typedoc """ 187 | The id of the recipient user of the DM. 188 | """ 189 | @type recipient_id :: Snowflake.t() 190 | 191 | @typedoc """ 192 | The parameters that can be passed to `create/2`. 193 | """ 194 | @type create_params :: Enum.t() | create_params_dm | create_params_guild 195 | 196 | @typedoc """ 197 | Parameters when creating a DM channel. 198 | """ 199 | @type create_params_dm :: %{recipient_id: recipient_id} 200 | 201 | @typedoc """ 202 | Parameters when creating a guild channel. 203 | """ 204 | @type create_params_guild :: %{ 205 | :guild_id => guild_id, 206 | :name => name, 207 | optional(:type) => type, 208 | optional(:topic) => topic, 209 | optional(:bitrate) => bitrate, 210 | optional(:user_limit) => user_limit, 211 | optional(:rate_limit_per_user) => rate_limit_per_user, 212 | optional(:position) => position, 213 | optional(:permission_overwrites) => permission_overwrites, 214 | optional(:parent_id) => parent_id, 215 | optional(:nsfw) => nsfw 216 | } 217 | 218 | @typedoc """ 219 | The parameters that can be passed to `update/2`. 220 | """ 221 | @type update_params :: Enum.t() | update_params_guild 222 | 223 | @typedoc """ 224 | Parameters when updating a guild channel. 225 | """ 226 | @type update_params_guild :: %{ 227 | optional(:type) => type, 228 | optional(:topic) => topic, 229 | optional(:bitrate) => bitrate, 230 | optional(:user_limit) => user_limit, 231 | optional(:rate_limit_per_user) => rate_limit_per_user, 232 | optional(:position) => position, 233 | optional(:permission_overwrites) => permission_overwrites, 234 | optional(:parent_id) => parent_id, 235 | optional(:nsfw) => nsfw 236 | } 237 | 238 | embedded_schema do 239 | field(:type, :integer) 240 | field(:position, :integer) 241 | field(:name, :string) 242 | field(:topic, :string) 243 | field(:nsfw, :boolean) 244 | field(:bitrate, :integer) 245 | field(:user_limit, :integer) 246 | field(:rate_limit_per_user, :integer) 247 | field(:icon, :string) 248 | field(:application_id, Snowflake) 249 | field(:rtc_region, :string) 250 | field(:video_quality_mode, :integer) 251 | 252 | field(:pinned_messages, :any, virtual: true) 253 | 254 | embeds_many(:recipients, User) 255 | 256 | has_many(:invites, Invite) 257 | has_many(:permission_overwrites, Overwrite) 258 | has_many(:webhooks, Webhook) 259 | has_many(:voice_states, VoiceState) 260 | 261 | belongs_to(:guild, Guild) 262 | belongs_to(:owner, User) 263 | belongs_to(:parent, Channel) 264 | end 265 | 266 | def fetch(id, options) do 267 | API.get("channels/#{id}", options) 268 | end 269 | 270 | def fetch_many(id, :invites, options) do 271 | API.get("channels/#{id}/invites", options) 272 | end 273 | 274 | def fetch_many(id, :permission_overwrites, options) do 275 | %Channel{permission_overwrites: overwrites} = get(id, options) 276 | {:ok, overwrites} 277 | end 278 | 279 | def fetch_many(id, :webhooks, options) do 280 | API.get("channels/#{id}/webhooks", options) 281 | end 282 | 283 | def insert(%{guild_id: guild_id} = params, options) do 284 | API.post("guilds/#{guild_id}/channels", params, options) 285 | end 286 | 287 | def insert(%{recipient_id: _recipient_id} = params, options) do 288 | API.post("users/@me/channels", params, options) 289 | end 290 | 291 | def patch(id, params, options) do 292 | API.patch("channels/#{id}", params, options) 293 | end 294 | 295 | def drop(id, options) do 296 | API.delete("channels/#{id}", options) 297 | end 298 | 299 | def preload(%Channel{recipients: recipients} = channel, :recipients, options) do 300 | recipients = 301 | recipients 302 | |> Stream.map(& &1.id) 303 | |> Stream.map(&User.get(&1, options)) 304 | |> Enum.to_list() 305 | 306 | %{channel | recipients: recipients} 307 | end 308 | 309 | def preload( 310 | %Channel{pinned_messages: [%Message{} | _rest]} = channel, 311 | :pinned_messages, 312 | options 313 | ) do 314 | if options[:force] do 315 | channel = %{channel | pinned_messages: nil} 316 | preload(channel, :pinned_messages, options) 317 | else 318 | channel 319 | end 320 | end 321 | 322 | def preload(%Channel{id: id} = channel, :pinned_messages, options) do 323 | pinned_messages = 324 | case API.get("channels/#{id}/pins", options) do 325 | {:ok, messages} -> 326 | Loader.load(Message, messages) 327 | 328 | {:error, error} -> 329 | error 330 | end 331 | 332 | %{channel | pinned_messages: pinned_messages} 333 | end 334 | 335 | def preload(channel, association, options) do 336 | super(channel, association, options) 337 | end 338 | 339 | @spec create(create_params, Loader.options()) :: Loader.result() 340 | def create(params, options) 341 | 342 | @spec update(t, update_params, Loader.options()) :: Loader.result() 343 | def update(struct, params, options) 344 | 345 | @doc """ 346 | Triggers the typing indicator on a given channel. 347 | """ 348 | @spec start_typing(t, Loader.options()) :: Loader.result() 349 | def start_typing(%Channel{id: id}, options \\ []) do 350 | API.post("channels/#{id}/typing", options) 351 | end 352 | 353 | @doc """ 354 | Delegates to `Coxir.Message.create/2`. 355 | """ 356 | @spec send_message(t, Enum.t(), Loader.options()) :: Loader.result() 357 | def send_message(%Channel{id: id}, params, options \\ []) do 358 | params 359 | |> Map.new() 360 | |> Map.put(:channel_id, id) 361 | |> Message.create(options) 362 | end 363 | 364 | @doc """ 365 | Delegates to `Coxir.Message.get/2`. 366 | """ 367 | @spec get_message(t, Snowflake.t(), Loader.options()) :: Message.t() | Error.t() 368 | def get_message(%Channel{id: id}, message_id, options \\ []) do 369 | Message.get({message_id, id}, options) 370 | end 371 | 372 | @doc """ 373 | Deletes messages in bulk from a given channel. 374 | 375 | This only works for messages not older than 2 weeks. 376 | """ 377 | @spec bulk_delete_messages(t, list(Message.t()) | list(Snowflake.t()), Loader.options()) :: 378 | Loader.result() 379 | def bulk_delete_messages(channel, messages, options \\ []) 380 | 381 | def bulk_delete_messages(channel, [%Message{} | _rest] = messages, options) do 382 | messages = Enum.map(messages, & &1.id) 383 | bulk_delete_messages(channel, messages, options) 384 | end 385 | 386 | def bulk_delete_messages(%Channel{id: id}, message_ids, options) do 387 | params = %{messages: message_ids} 388 | API.post("channels/#{id}/messages/bulk-delete", params, options) 389 | end 390 | 391 | @doc """ 392 | Delegates to `Coxir.Invite.create/2`. 393 | """ 394 | @spec create_invite(t, Enum.t(), Loader.options()) :: Loader.result() 395 | def create_invite(%Channel{id: id}, params \\ %{}, options \\ []) do 396 | params 397 | |> Map.new() 398 | |> Map.put(:channel_id, id) 399 | |> Invite.create(options) 400 | end 401 | 402 | @doc """ 403 | Delegates to `Coxir.Overwrite.create/2`. 404 | """ 405 | @spec create_overwrite(t, Enum.t(), Loader.options()) :: Loader.result() 406 | def create_overwrite(%Channel{id: id}, params, options \\ []) do 407 | params 408 | |> Map.new() 409 | |> Map.put(:channel_id, id) 410 | |> Overwrite.create(options) 411 | end 412 | 413 | @doc """ 414 | Delegates to `Coxir.Webhook.create/2`. 415 | """ 416 | @spec create_webhook(t, Enum.t(), Loader.options()) :: Loader.result() 417 | def create_webhook(%Channel{id: id}, params, options \\ []) do 418 | params 419 | |> Map.new() 420 | |> Map.put(:channel_id, id) 421 | |> Webhook.create(options) 422 | end 423 | end 424 | --------------------------------------------------------------------------------