├── lib ├── billing_task.ex ├── .DS_Store ├── connectivity │ ├── .DS_Store │ ├── lavalink │ │ ├── .DS_Store │ │ ├── stage.ex │ │ ├── lavapotion.ex │ │ ├── stage │ │ │ ├── producer.ex │ │ │ ├── consumer.ex │ │ │ └── middle.ex │ │ ├── struct │ │ │ ├── client.ex │ │ │ ├── payloads.ex │ │ │ ├── player.ex │ │ │ └── node.ex │ │ └── api.ex │ ├── statsd.ex │ ├── supreme_monitor.ex │ └── redis.ex ├── module_executor │ ├── .DS_Store │ ├── modules │ │ ├── .DS_Store │ │ ├── cc_module.ex │ │ ├── cloudcord_info │ │ │ └── cloudcord_info.ex │ │ ├── module_map.ex │ │ ├── supreme_monitor │ │ │ └── supreme_monitor.ex │ │ ├── urban_dictionary │ │ │ └── urban_dictionary.ex │ │ ├── release_calendar │ │ │ └── release_calendar.ex │ │ ├── adidas_gen │ │ │ └── adidas_gen.ex │ │ ├── stockx_search │ │ │ └── stockx_search.ex │ │ ├── music │ │ │ ├── lavalink_manager.ex │ │ │ └── music.ex │ │ └── moderation │ │ │ └── moderation.ex │ ├── match.ex │ ├── actions │ │ ├── action_map.ex │ │ ├── other_actions.ex │ │ └── discord_actions.ex │ ├── snowflake.ex │ ├── module_center.ex │ ├── command_center.ex │ └── discord_permission.ex ├── structs │ ├── member_state.ex │ ├── channel_state.ex │ ├── role_state.ex │ ├── module.ex │ └── guild_state.ex ├── discord_gateway_gs.ex ├── discord_gateway_client │ ├── heartbeat.ex │ ├── utility.ex │ ├── redis_sync.ex │ └── client.ex ├── node_manager.ex └── bot_entity.ex ├── test ├── test_helper.exs └── discord_gateway_gs_test.exs ├── rel ├── plugins │ └── .gitignore ├── vm.args └── config.exs ├── .vscode └── settings.json ├── .DS_Store ├── dump.rdb ├── .formatter.exs ├── vm.args ├── kube-gcp-roles.yml ├── config ├── config.exs └── prod.exs ├── README.md ├── .gitignore ├── mix.exs ├── Dockerfile ├── discord-gateway-gs.yml └── mix.lock /lib/billing_task.ex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /rel/plugins/.gitignore: -------------------------------------------------------------------------------- 1 | *.* 2 | !*.exs 3 | !.gitignore -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "discord.enabled": true 3 | } -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcord/cloudcore/HEAD/.DS_Store -------------------------------------------------------------------------------- /dump.rdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcord/cloudcore/HEAD/dump.rdb -------------------------------------------------------------------------------- /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcord/cloudcore/HEAD/lib/.DS_Store -------------------------------------------------------------------------------- /lib/connectivity/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcord/cloudcore/HEAD/lib/connectivity/.DS_Store -------------------------------------------------------------------------------- /lib/module_executor/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcord/cloudcore/HEAD/lib/module_executor/.DS_Store -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/connectivity/lavalink/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcord/cloudcore/HEAD/lib/connectivity/lavalink/.DS_Store -------------------------------------------------------------------------------- /lib/module_executor/modules/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudcord/cloudcore/HEAD/lib/module_executor/modules/.DS_Store -------------------------------------------------------------------------------- /vm.args: -------------------------------------------------------------------------------- 1 | ## Name of the node 2 | -name ${MY_BASENAME}@${MY_POD_IP} 3 | ## Cookie for distributed erlang 4 | -setcookie ${ERLANG_COOKIE} 5 | # Enable SMP automatically based on availability 6 | -smp auto -------------------------------------------------------------------------------- /test/discord_gateway_gs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGsTest do 2 | use ExUnit.Case 3 | doctest DiscordGatewayGs 4 | 5 | test "greets the world" do 6 | assert DiscordGatewayGs.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/module_executor/match.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Match do 2 | def verify_command_match(command, args) do 3 | l = length(args) 4 | {min_args, max_args} = args 5 | min_args <= l 6 | end 7 | 8 | 9 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/cc_module.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.CCModule do 2 | @callback handle_event(atom(), Map.t(), String.t()) :: :ok | nil 3 | @callback handle_command(tuple(), Map.t(), Map.t()) :: :ok | nil 4 | @optional_callbacks handle_event: 3, handle_command: 3 5 | end -------------------------------------------------------------------------------- /lib/structs/member_state.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.Structs.MemberState do 2 | @type t :: %{ 3 | roles: List.t(), 4 | user: Map.t(), 5 | nick: String.t() | nil, 6 | voice: Map.t() | nil 7 | } 8 | 9 | defstruct roles: nil, 10 | user: nil, 11 | nick: nil, 12 | voice: nil 13 | end -------------------------------------------------------------------------------- /lib/structs/channel_state.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.Structs.ChannelState do 2 | @type t :: %{ 3 | id: Number.t(), 4 | name: String.t(), 5 | nsfw: boolean, 6 | type: Number.t(), 7 | position: Number.t(), 8 | topic: String.t() | nil 9 | } 10 | 11 | defstruct id: nil, 12 | name: nil, 13 | nsfw: nil, 14 | type: nil, 15 | position: nil, 16 | topic: nil 17 | end -------------------------------------------------------------------------------- /lib/module_executor/actions/action_map.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Actions.ActionMap do 2 | alias DiscordGatewayGs.ModuleExecutor.Actions.{DiscordActions, OtherActions} 3 | 4 | def actions do 5 | %{ 6 | "SEND_MESSAGE_TO_CHANNEL" => &DiscordActions.send_message_to_channel/3, 7 | "SEND_EMBED_TO_CHANNEL" => &DiscordActions.send_embed_to_channel/3, 8 | "HTTP_POST" => &OtherActions.http_post/4 9 | } 10 | end 11 | end -------------------------------------------------------------------------------- /lib/connectivity/statsd.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.Statix do 2 | use Instruments 3 | 4 | def node_name do 5 | System.get_env("MY_POD_NAME") || "dev" 6 | end 7 | 8 | def increment_messages() do 9 | Instruments.increment("#{node_name}.mps", 1) 10 | end 11 | 12 | def increment_gwe(), 13 | do: Instruments.increment("#{node_name}.gwe", 1) 14 | 15 | def set_bots_running_gauge(amount), 16 | do: Instruments.gauge("#{node_name}.bots_running", amount) 17 | end -------------------------------------------------------------------------------- /kube-gcp-roles.yml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: ex_libcluster 5 | rules: 6 | - apiGroups: [""] 7 | resources: ["endpoints"] 8 | verbs: ["get", "list", "watch"] 9 | --- 10 | apiVersion: rbac.authorization.k8s.io/v1 11 | kind: RoleBinding 12 | metadata: 13 | name: give-default-sa-libcluster 14 | subjects: 15 | - kind: ServiceAccount 16 | name: default 17 | roleRef: 18 | kind: Role 19 | name: ex_libcluster 20 | apiGroup: rbac.authorization.k8s.io 21 | --- -------------------------------------------------------------------------------- /lib/connectivity/lavalink/stage.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Stage do 2 | use Supervisor 3 | 4 | alias LavaPotion.Stage.{Producer, Middle} 5 | 6 | def start_link() do 7 | Supervisor.start_link(__MODULE__, [], name: __MODULE__) 8 | end 9 | 10 | def init(_arg) do 11 | children = [ 12 | worker(Producer, []), 13 | worker(Middle, []) 14 | ] 15 | options = [ 16 | strategy: :one_for_one, 17 | name: __MODULE__ 18 | ] 19 | Supervisor.init(children, options) 20 | end 21 | end -------------------------------------------------------------------------------- /lib/structs/role_state.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.Structs.RoleState do 2 | @type t :: %{ 3 | id: Number.t(), 4 | color: Number.t() | 0, 5 | hoist: boolean, 6 | managed: boolean, 7 | mentionable: boolean, 8 | name: String.t() | nil, 9 | permissions: Number.t(), 10 | position: Number.t() 11 | } 12 | 13 | defstruct id: nil, 14 | color: nil, 15 | hoist: nil, 16 | managed: nil, 17 | mentionable: nil, 18 | name: nil, 19 | permissions: nil, 20 | position: nil 21 | end -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # INTERNAL KUBERNETES STATSD ENDPOINT: 10.59.254.231 6 | 7 | config :statix, 8 | prefix: "", 9 | host: "10.59.254.231", 10 | port: 8125 11 | 12 | config :instruments, 13 | reporter_module: Instruments.Statix, 14 | fast_counter_report_interval: 2_000, 15 | probe_prefix: "probes", 16 | statsd_port: 8125 17 | 18 | config :discord_gateway_gs, 19 | redis_host: "localhost", 20 | internal_api: "http://localhost:8888" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## DISCLAIMER 2 | I pretty much created this project to learn Elixir. A lot of things aren't using the best practices, but most of it is pretty good. 3 | 4 | # cloudcore 5 | 6 | Cloudcore is the backend service that makes CloudCord work. It contains a variety of features, including the part that actually hosts the bots, connects them to Discord, and adds a command and module interface with them. It then exposes itself to Redis Pub/Sub to receive events from the API and other services. 7 | 8 | Feel free to make an issue or [contact me on Twitter](https://twitter.com/phineyes) if you have questions about how something works. 9 | -------------------------------------------------------------------------------- /lib/structs/module.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.Structs.Module do 2 | @type t :: %{ 3 | enabled: boolean, 4 | icon: String.t() | nil, 5 | commands: Map.t(), 6 | description: String.t(), 7 | name: String.t(), 8 | internal_module: boolean| nil, 9 | internal_reference: String.t() | nil, 10 | gwe_sub: List.t() | nil 11 | } 12 | 13 | defstruct enabled: nil, 14 | commands: nil, 15 | description: nil, 16 | icon: nil, 17 | name: nil, 18 | internal_module: nil, 19 | internal_reference: nil, 20 | gwe_sub: nil 21 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/cloudcord_info/cloudcord_info.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.CloudCordInfo do 2 | @behaviour DiscordGatewayGs.ModuleExecutor.CCModule 3 | 4 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 5 | 6 | def handle_command({"ccinfo", args}, %{"status" => %{"node" => node, "pid" => pid}, "name" => name, "authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{"channel_id" => channel}} = discord_payload) do 7 | [{_, node_name}] = :ets.lookup(:node_info, "identifier"); 8 | msg = "**CloudCord Info: `#{name}`**\nNode: `#{node_name}`" 9 | 10 | DiscordActions.send_message_to_channel(msg, channel, token) 11 | end 12 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/module_map.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.ModuleMap do 2 | def modules do 3 | %{ 4 | "cloudcord_info" => DiscordGatewayGs.ModuleExecutor.Modules.CloudCordInfo, 5 | "urban_dictionary" => DiscordGatewayGs.ModuleExecutor.Modules.UrbanDictionary, 6 | "music" => DiscordGatewayGs.ModuleExecutor.Modules.Music, 7 | "stockx_search" => DiscordGatewayGs.ModuleExecutor.Modules.StockX, 8 | "release_calendar" => DiscordGatewayGs.ModuleExecutor.Modules.ReleaseCalendar, 9 | "adidas_gen" => DiscordGatewayGs.ModuleExecutor.Modules.AdidasGen, 10 | "moderation" => DiscordGatewayGs.ModuleExecutor.Modules.Moderation 11 | } 12 | end 13 | end -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .elixir_ls/ 2 | # The directory Mix will write compiled artifacts to. 3 | /_build/ 4 | 5 | # If you run "mix test --cover", coverage assets end up here. 6 | /cover/ 7 | 8 | # The directory Mix downloads your dependencies sources to. 9 | /deps/ 10 | 11 | # Where 3rd-party dependencies like ExDoc output generated docs. 12 | /doc/ 13 | 14 | # Ignore .fetch files in case you like to edit your project deps locally. 15 | /.fetch 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | *.ez 22 | 23 | # Ignore package tarball (built via "mix hex.build"). 24 | discord_gateway_gs-*.tar 25 | 26 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | config :libcluster, 2 | topologies: [ 3 | firehose: [ 4 | strategy: Cluster.Strategy.Kubernetes, 5 | config: [ 6 | # mode: :dns, 7 | kubernetes_node_basename: "discord-gateway-gs", 8 | kubernetes_selector: "app=discord-gateway-gs", 9 | polling_interval: 10_000, 10 | ] 11 | ] 12 | ] 13 | 14 | config :statix, 15 | prefix: "", 16 | host: "10.59.254.231", 17 | port: 8125 18 | 19 | config :instruments, 20 | reporter_module: Instruments.Statix, 21 | fast_counter_report_interval: 2_000, 22 | probe_prefix: "probes", 23 | statsd_port: 8125 24 | 25 | config :discord_gateway_gs, 26 | redis_host: "redis-master", 27 | internal_api: "http://internal-data-api" -------------------------------------------------------------------------------- /lib/structs/guild_state.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.Structs.GuildState do 2 | @type t :: %{ 3 | id: Number.t(), 4 | icon: String.t() | nil, 5 | member_count: Number.t(), 6 | large: boolean, 7 | mfa_level: Number.t(), 8 | owner_id: Number.t(), 9 | premium_tier: Number.t() | nil, 10 | unavailable: boolean, 11 | verification_level: Number.t(), 12 | vanity_url_code: String.t() | nil 13 | } 14 | 15 | defstruct id: nil, 16 | icon: nil, 17 | member_count: nil, 18 | large: nil, 19 | name: nil, 20 | mfa_level: nil, 21 | owner_id: nil, 22 | premium_tier: nil, 23 | unavailable: nil, 24 | verification_level: nil, 25 | vanity_url_code: nil 26 | end -------------------------------------------------------------------------------- /lib/discord_gateway_gs.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | import Supervisor.Spec, warn: false 6 | 7 | topologies = Application.get_env(:libcluster, :topologies) 8 | 9 | children = [ 10 | {Horde.Registry, [name: DiscordGatewayGs.GSRegistry, keys: :unique]}, 11 | {Horde.Supervisor, [name: DiscordGatewayGs.DistributedSupervisor, strategy: :one_for_one]}, 12 | worker(DiscordGatewayGs.NodeManager, []), 13 | worker(DiscordGatewayGs.RedisConnector, []), 14 | #worker(DiscordGatewayGs.Connectivity.SupremeMonitor, []), 15 | ] 16 | 17 | #DiscordGatewayGs.Statix.connect() 18 | 19 | opts = [strategy: :one_for_one, name: DiscordGatewayGs.Supervisor] 20 | Supervisor.start_link(children, opts) 21 | end 22 | end -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :discord_gateway_gs, 7 | version: "0.1.0", 8 | elixir: "~> 1.6", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger, :libcluster, :httpoison], 17 | mod: {DiscordGatewayGs, []} 18 | ] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:websocket_client, "~> 1.2.4"}, 24 | {:httpoison, "~> 1.4"}, 25 | {:poison, "~> 3.1"}, 26 | {:redix, ">= 0.9.0"}, 27 | {:horde, "~> 0.4.0-rc.2"}, 28 | {:libcluster, "~> 3.0.3"}, 29 | {:distillery, "~> 2.0"}, 30 | {:instruments, "~>1.1.1"}, 31 | {:websockex, "~> 0.4.0"}, 32 | {:gen_stage, "~> 0.14"} 33 | ] 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM bitwalker/alpine-elixir:1.7.4 as build 2 | 3 | COPY . . 4 | 5 | #Install dependencies and build Release 6 | RUN export MIX_ENV=prod && \ 7 | rm -Rf _build && \ 8 | mix deps.get && \ 9 | mix release 10 | 11 | #Extract Release archive to /rel for copying in next stage 12 | RUN APP_NAME="discord_gateway_gs" && \ 13 | RELEASE_DIR=`ls -d _build/prod/rel/$APP_NAME/releases/*/` && \ 14 | mkdir /export && \ 15 | tar -xf "$RELEASE_DIR/$APP_NAME.tar.gz" -C /export 16 | 17 | #================ 18 | #Deployment Stage 19 | #================ 20 | FROM pentacent/alpine-erlang-base:latest 21 | 22 | #Set environment variables and expose port 23 | ENV REPLACE_OS_VARS=true 24 | 25 | #Copy and extract .tar.gz Release file from the previous stage 26 | COPY --from=build /export/ . 27 | 28 | USER default 29 | 30 | ENTRYPOINT ["/opt/app/bin/discord_gateway_gs"] 31 | CMD ["foreground"] 32 | -------------------------------------------------------------------------------- /rel/vm.args: -------------------------------------------------------------------------------- 1 | ## This file provide the arguments provided to the VM at startup 2 | ## You can find a full list of flags and their behaviours at 3 | ## http://erlang.org/doc/man/erl.html 4 | 5 | ## Name of the node 6 | -name <%= release_name %>@127.0.0.1 7 | 8 | ## Cookie for distributed erlang 9 | -setcookie <%= release.profile.cookie %> 10 | 11 | ## Heartbeat management; auto-restarts VM if it dies or becomes unresponsive 12 | ## (Disabled by default..use with caution!) 13 | ##-heart 14 | 15 | ## Enable kernel poll and a few async threads 16 | ##+K true 17 | ##+A 5 18 | ## For OTP21+, the +A flag is not used anymore, 19 | ## +SDio replace it to use dirty schedulers 20 | ##+SDio 5 21 | 22 | ## Increase number of concurrent ports/sockets 23 | ##-env ERL_MAX_PORTS 4096 24 | 25 | ## Tweak GC to run more often 26 | ##-env ERL_FULLSWEEP_AFTER 10 27 | 28 | # Enable SMP automatically based on availability 29 | # On OTP21+, this is not needed anymore. 30 | -smp auto 31 | -------------------------------------------------------------------------------- /lib/connectivity/lavalink/lavapotion.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion do 2 | use Application 3 | use Supervisor 4 | 5 | alias LavaPotion.Stage 6 | 7 | def start do 8 | children = [ 9 | supervisor(Stage, []) 10 | ] 11 | options = [ 12 | strategy: :one_for_one, 13 | name: __MODULE__ 14 | ] 15 | Supervisor.start_link(children, options) 16 | LavaPotion.Stage.Consumer.start_link(DiscordGatewayGs.ModuleExecutor.Modules.Music.LavalinkManager) 17 | end 18 | 19 | defmacro __using__(_opts) do 20 | quote do 21 | alias LavaPotion.Struct.{Client, Node, Player} 22 | alias LavaPotion.Api 23 | 24 | require Logger 25 | 26 | def start_link() do 27 | LavaPotion.Stage.Consumer.start_link(__MODULE__) 28 | end 29 | 30 | def handle_track_event(event, state) do 31 | Logger.warn "Unhandled Event: #{inspect event}" 32 | {:ok, state} 33 | end 34 | 35 | defoverridable [handle_track_event: 2] 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /lib/connectivity/lavalink/stage/producer.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Stage.Producer do 2 | use GenStage 3 | 4 | def start_link() do 5 | GenStage.start_link(__MODULE__, [], name: __MODULE__) 6 | end 7 | 8 | def init(_state) do 9 | {:producer, {:queue.new(), 0}} 10 | end 11 | 12 | def notify(event) do 13 | GenStage.cast(__MODULE__, {:notify, event}) 14 | end 15 | 16 | def handle_demand(new, {queue, demand}) do 17 | queue_events({demand + new, []}, queue) 18 | end 19 | 20 | def handle_cast({:notify, event}, {queue, demand}) do 21 | queue = :queue.in(event, queue) 22 | queue_events({demand, []}, queue) 23 | end 24 | 25 | defp queue_events({0, current}, queue) do 26 | {:noreply, Enum.reverse(current), {queue, 0}} 27 | end 28 | defp queue_events({demand, current}, queue) do 29 | case :queue.out(queue) do 30 | {{:value, val}, queue} -> 31 | queue_events({demand - 1, [val | current]}, queue) 32 | _ -> 33 | {:noreply, Enum.reverse(current), {queue, demand}} 34 | end 35 | end 36 | end -------------------------------------------------------------------------------- /lib/connectivity/lavalink/stage/consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Stage.Consumer do 2 | use GenStage 3 | 4 | alias LavaPotion.Stage.Middle 5 | 6 | require Logger 7 | 8 | def start_link(handler) do 9 | GenStage.start_link(__MODULE__, %{handler: handler, public: []}, name: __MODULE__) 10 | end 11 | 12 | def init(state), do: {:consumer, state, subscribe_to: [Middle]} 13 | 14 | def handle_events(events, _from, state) do 15 | new = handle(events, state) 16 | {:noreply, [], new} 17 | end 18 | 19 | def handle([], state), do: state 20 | def handle([args = [type, _args] | events], map = %{handler: handler}) do 21 | handler 22 | |> apply(:handle_track_event, args) 23 | |> case do 24 | {:ok, state} -> 25 | Logger.debug "Handled Event: #{inspect type}" 26 | handle(events, %{map | public: state}) 27 | term -> 28 | raise "expected {:ok, state}, got #{inspect term}" 29 | end 30 | end 31 | def handle(_data, map) do 32 | Logger.warn "Unhandled Event: #{inspect map}" 33 | end 34 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/supreme_monitor/supreme_monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.SupremeMonitor do 2 | @behaviour DiscordGatewayGs.ModuleExecutor.CCModule 3 | 4 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 5 | 6 | def handle_outer_event({:restock, product}, channel_id, token) do 7 | %{"id" => style_id, "name" => name, "image_url_hi" => image, "sizes" => sizes} = product |> Poison.decode! 8 | 9 | IO.inspect sizes 10 | DiscordActions.send_embed_to_channel( 11 | %{ 12 | "thumbnail" => %{ 13 | "url" => "https:" <> image 14 | }, 15 | "title" => "Monitor", 16 | "fields" => Enum.map(sizes, fn s -> 17 | %{ 18 | "name" => s["name"], 19 | "value" => "[[ATC]](http://atc.bz/p?a=a&b=&p=304371&st=#{style_id}&s=#{s["id"]}&c=uk)", 20 | "inline" => true 21 | } 22 | end) 23 | }, 24 | "535097935923380246", 25 | "NDEwOTI4OTU4NTE1OTcwMDQ4.XNOHPg.e94qEP8W1PiJJ0L5hG35V0cv6ds" 26 | ) 27 | end 28 | end -------------------------------------------------------------------------------- /lib/connectivity/lavalink/struct/client.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Struct.Client do 2 | defstruct default_password: "youshallnotpass", default_port: 2333, user_id: nil, shard_count: 1 3 | 4 | @typedoc """ 5 | 6 | """ 7 | @type t :: %__MODULE__{} 8 | 9 | def new(opts) do 10 | user_id = opts[:user_id] 11 | if !is_binary(user_id) do 12 | raise "user id not a binary string or == nil" 13 | end 14 | 15 | shard_count = opts[:shard_count] || 1 16 | if !is_number(shard_count) do 17 | raise "shard count not a number or == nil" 18 | end 19 | 20 | default_port = opts[:default_port] || 2333 21 | if !is_number(default_port) do 22 | raise "default port not a number or == nil" 23 | end 24 | 25 | default_password = opts[:default_password] || "youshallnotpass" 26 | if !is_binary(default_password) do 27 | raise "default password not a binary string or == nil" 28 | end 29 | 30 | %__MODULE__{default_password: default_password, default_port: default_port, user_id: user_id, shard_count: shard_count} 31 | end 32 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/urban_dictionary/urban_dictionary.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.UrbanDictionary do 2 | @behaviour DiscordGatewayGs.ModuleExecutor.CCModule 3 | 4 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 5 | 6 | def handle_command({command, args}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{"channel_id" => channel}} = discord_payload) do 7 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":mag_right: Looking up...", channel, token) 8 | 9 | query = args 10 | |> Enum.join(" ") 11 | |> URI.encode 12 | 13 | case HTTPoison.get("https://api.urbandictionary.com/v0/define?term=#{query}") do 14 | {_, %HTTPoison.Response{:body => b}} -> 15 | case Poison.decode!(b) do 16 | %{"list" => [d | _]} -> 17 | msg = "**Urban Dictionary: `#{d["word"]}`**\nDefinition: *#{d["definition"]}*" 18 | DiscordActions.edit_message(msg, message_to_edit, channel, token) 19 | _ -> DiscordActions.edit_message(":x: That definition isn't in the Urban Dictionary!") 20 | end 21 | _ -> 22 | DiscordActions.edit_message(":x: Sorry, an error occurred fetching UrbanDictionary.", message_to_edit, channel, token) 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /lib/connectivity/supreme_monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.Connectivity.SupremeMonitor do 2 | use WebSockex 3 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 4 | 5 | def start_link do 6 | WebSockex.start_link("ws://192.168.0.69:8080", __MODULE__, %{}) 7 | end 8 | 9 | def handle_frame({type, msg}, state) do 10 | %{"pid" => id, "pname" => pname, "name" => name, "image_url_hi" => image, "size" => %{"name" => size_name}} = Poison.decode!(msg) 11 | IO.inspect name 12 | DiscordActions.send_embed_to_channel( 13 | %{ 14 | "thumbnail" => %{ 15 | "url" => image 16 | }, 17 | "title" => pname, 18 | "fields" => [ 19 | %{ 20 | "name" => "Style", 21 | "value" => name, 22 | "inline" => true 23 | }, 24 | %{ 25 | "name" => "Size", 26 | "value" => size_name, 27 | "inline" => true 28 | }, 29 | %{ 30 | "name" => "Links", 31 | "value" => "[Product Link](https://www.supremenewyork.com/shop/#{id})", 32 | "inline" => true 33 | } 34 | ] 35 | }, 36 | "535097935923380246", 37 | "NDEwOTI4OTU4NTE1OTcwMDQ4.XNOHPg.e94qEP8W1PiJJ0L5hG35V0cv6ds" 38 | ) 39 | {:ok, state} 40 | end 41 | 42 | def handle_cast({:send, {type, msg} = frame}, state) do 43 | IO.puts "Sending #{type} frame with payload: #{msg}" 44 | {:reply, frame, state} 45 | end 46 | end -------------------------------------------------------------------------------- /discord-gateway-gs.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: discord-gateway-gs 5 | labels: 6 | app: discord-gateway-gs 7 | tier: backend 8 | spec: 9 | replicas: 1 10 | template: 11 | metadata: 12 | labels: 13 | app: discord-gateway-gs 14 | tier: backend 15 | spec: 16 | containers: 17 | - name: gatewaygs-cluster 18 | image: gcr.io/cloudcord/discord-gateway-gs 19 | imagePullPolicy: Always 20 | resources: 21 | limits: 22 | cpu: "1" 23 | requests: 24 | cpu: "0.5" 25 | env: 26 | - name: MIX_ENV 27 | value: prod 28 | - name: MY_BASENAME 29 | value: discord-gateway-gs 30 | - name: MY_POD_NAMESPACE 31 | valueFrom: 32 | fieldRef: 33 | fieldPath: metadata.namespace 34 | - name: MY_POD_IP 35 | valueFrom: 36 | fieldRef: 37 | fieldPath: status.podIP 38 | - name: MY_POD_NAME 39 | valueFrom: 40 | fieldRef: 41 | fieldPath: metadata.name 42 | - name: REPLACE_OS_VARS 43 | value: "true" 44 | - name: RELEASE_CONFIG_DIR 45 | value: /beamconfig 46 | - name: ERLANG_COOKIE 47 | valueFrom: 48 | secretKeyRef: 49 | name: app-config 50 | key: erlang-cookie 51 | volumeMounts: 52 | - name: config-volume 53 | mountPath: /beamconfig 54 | volumes: 55 | - name: config-volume 56 | configMap: 57 | name: vm-config 58 | -------------------------------------------------------------------------------- /lib/connectivity/lavalink/struct/payloads.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Struct.VoiceUpdate do 2 | @derive [Poison.Encoder] 3 | defstruct [:guildId, :sessionId, :event, op: "voiceUpdate"] 4 | end 5 | 6 | defmodule LavaPotion.Struct.Play do 7 | @derive [Poison.Encoder] 8 | defstruct [:guildId, :track, op: "play"] # startTime and endTime disabled for now 9 | end 10 | 11 | defmodule LavaPotion.Struct.Stop do 12 | @derive [Poison.Encoder] 13 | defstruct [:guildId, op: "stop"] 14 | end 15 | 16 | defmodule LavaPotion.Struct.Destroy do 17 | @derive [Poison.Encoder] 18 | defstruct [:guildId, op: "destroy"] 19 | end 20 | 21 | defmodule LavaPotion.Struct.Volume do 22 | @derive [Poison.Encoder] 23 | defstruct [:guildId, :volume, op: "volume"] 24 | end 25 | 26 | defmodule LavaPotion.Struct.Pause do 27 | @derive [Poison.Encoder] 28 | defstruct [:guildId, :pause, op: "pause"] 29 | end 30 | 31 | defmodule LavaPotion.Struct.Seek do 32 | @derive [Poison.Encoder] 33 | defstruct [:guildId, :position, op: "seek"] 34 | end 35 | 36 | defmodule LavaPotion.Struct.Equalizer do 37 | @derive [Poison.Encoder] 38 | defstruct [:guildId, :bands, op: "equalizer"] 39 | end 40 | 41 | defmodule LavaPotion.Struct.LoadTrackResponse do 42 | @derive [Poison.Encoder] 43 | defstruct [:loadType, :playlistInfo, :tracks] 44 | end 45 | 46 | defmodule LavaPotion.Struct.AudioTrack do 47 | @derive [Poison.Encoder] 48 | defstruct [:title, :author, :length, :identifier, :uri, :isStream, :isSeekable, :position] 49 | end 50 | 51 | defmodule LavaPotion.Struct.Stats do 52 | @derive [Poison.Encoder] 53 | defstruct [:players, :playing_players, :uptime, :memory, :cpu, :frame_stats] 54 | end -------------------------------------------------------------------------------- /lib/module_executor/actions/other_actions.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Actions.OtherActions do 2 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 3 | 4 | def http_post(%{"url" => url} = params, payload, %{"id" => bot_id, "authorization" => %{"discord_token" => token}}, emitter) do 5 | [{_, node_name}] = :ets.lookup(:node_info, "identifier") 6 | 7 | payload = payload.data 8 | |> Map.put("id", Integer.to_string(payload.data["id"])) 9 | |> Map.put("channel_id", Integer.to_string(payload.data["channel_id"])) 10 | |> Map.put("guild_id", Integer.to_string(payload.data.guild_id)) 11 | |> Map.put("author", Map.put(payload.data["author"], "id", Integer.to_string(payload.data["author"]["id"]))) 12 | 13 | {_, %HTTPoison.Response{:body => b, :status_code => status}} = HTTPoison.post( 14 | url, 15 | Poison.encode!(payload), 16 | [ 17 | {"X-CloudCord-BotID", bot_id}, 18 | {"X-CloudCord-Emitter", emitter}, 19 | {"X-CloudCord-APIBase", "https://api-dev.cloudcord.io"}, 20 | {"X-CloudCord-Node", node_name}, 21 | {"User-Agent", "cloudcord/1.1 (#{node_name})"}, 22 | {"Content-Type", "application/json"} 23 | ], 24 | [ 25 | recv_timeout: 5000 26 | ] 27 | ) 28 | 29 | with status <- 200 do 30 | case Poison.decode!(b) do 31 | %{"action" => "SEND_MESSAGE_TO_CHANNEL", "parameters" => params} -> 32 | DiscordActions.send_message_to_channel(params["message"], params["channel_id"], token) 33 | %{"action" => "SEND_EMBED_TO_CHANNEL", "parameters" => params} -> 34 | DiscordActions.send_embed_to_channel(params["embed"], params["channel_id"], token) 35 | _ -> {:error, "invalid_action"} 36 | end 37 | end 38 | end 39 | end -------------------------------------------------------------------------------- /lib/connectivity/lavalink/api.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Api do 2 | alias LavaPotion.Struct.{LoadTrackResponse, AudioTrack, Player, Node} 3 | 4 | def initialize(pid, guild_id, session_id, token, endpoint) when is_pid(pid) and is_binary(guild_id) and is_binary(session_id) and is_binary(token) and is_binary(endpoint) do 5 | WebSockex.cast(pid, {:voice_update, %Player{guild_id: guild_id, session_id: session_id, token: token, endpoint: endpoint, is_real: false}}) 6 | end 7 | 8 | def initialize(pid, player = %Player{is_real: false}) when is_pid(pid) do 9 | WebSockex.cast(pid, {:voice_update, player}) 10 | end 11 | 12 | def load_tracks(identifier) do 13 | {:ok, node} = Node.best_node() 14 | load_tracks(node, identifier) 15 | end 16 | 17 | def load_tracks(%Node{address: address, port: port, password: password}, identifier) do 18 | load_tracks(address, port, password, identifier) 19 | end 20 | 21 | def load_tracks(address, port, password, identifier) when is_binary(address) and is_number(port) and is_binary(password) and is_binary(identifier) do 22 | HTTPoison.get!("http://#{address}:#{port}/loadtracks?identifier=#{URI.encode(identifier)}", ["Authorization": password]).body 23 | |> Poison.decode!(as: %LoadTrackResponse{}) 24 | end 25 | 26 | def decode_track(track) do 27 | {:ok, node} = Node.best_node() 28 | decode_track(node, track) 29 | end 30 | 31 | def decode_track(%Node{address: address, port: port, password: password}, track) do 32 | decode_track(address, port, password, track) 33 | end 34 | 35 | def decode_track(address, port, password, track) when is_binary(address) and is_number(port) and is_binary(password) and is_binary(track) do 36 | HTTPoison.get!("http://#{address}:#{port}/decodetrack?track=#{URI.encode(track)}", ["Authorization": password]).body 37 | |> Poison.decode!(as: %AudioTrack{}) 38 | end 39 | end -------------------------------------------------------------------------------- /lib/module_executor/snowflake.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Snowflake do 2 | @moduledoc """ 3 | Functions that work on Snowflakes. 4 | """ 5 | 6 | @typedoc """ 7 | The type that represents snowflakes in JSON. 8 | 9 | In JSON, Snowflakes are typically represented as strings due 10 | to some languages not being able to represent such a large number. 11 | """ 12 | @type external_snowflake :: String.t() 13 | 14 | @typedoc """ 15 | The snowflake type. 16 | 17 | Snowflakes are 64-bit unsigned integers used to represent discord 18 | object ids. 19 | """ 20 | @type t :: 0..0xFFFFFFFFFFFFFFFF 21 | 22 | @doc ~S""" 23 | Returns `true` if `term` is a snowflake; otherwise returns `false`. 24 | """ 25 | defguard is_snowflake(term) 26 | when is_integer(term) and term in 0..0xFFFFFFFFFFFFFFFF 27 | 28 | @doc ~S""" 29 | Attempts to convert a term into a snowflake. 30 | """ 31 | @spec cast(term) :: {:ok, t | nil} | :error 32 | def cast(value) 33 | def cast(nil), do: {:ok, nil} 34 | def cast(value) when is_snowflake(value), do: {:ok, value} 35 | 36 | def cast(value) when is_binary(value) do 37 | case Integer.parse(value) do 38 | {snowflake, _} -> cast(snowflake) 39 | _ -> :error 40 | end 41 | end 42 | 43 | def cast(_), do: :error 44 | 45 | @doc """ 46 | Same as `cast/1`, except it raises an `ArgumentError` on failure. 47 | """ 48 | @spec cast!(term) :: t | nil | no_return 49 | def cast!(value) do 50 | case cast(value) do 51 | {:ok, res} -> res 52 | :error -> raise ArgumentError, "Could not convert to a snowflake" 53 | end 54 | end 55 | 56 | @doc ~S""" 57 | Convert a snowflake into its external representation. 58 | """ 59 | @spec dump(t) :: external_snowflake 60 | def dump(snowflake) when is_snowflake(snowflake), do: to_string(snowflake) 61 | 62 | # https://raw.githubusercontent.com/Kraigie/nostrum/master/lib/nostrum/snowflake.ex 63 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/release_calendar/release_calendar.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.ReleaseCalendar do 2 | @behaviour DiscordGatewayGs.ModuleExecutor.DiscordActions 3 | 4 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 5 | 6 | def handle_command({"release", _}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{"channel_id" => channel}} = discord_payload) do 7 | case HTTPoison.get("https://solelinks.com/api/releases?page=1") do 8 | {_, %HTTPoison.Response{:body => b}} -> 9 | %{"data" => %{"data" => [i | _]}} = b |> Poison.decode! 10 | 11 | embed = %{ 12 | "thumbnail" => %{ 13 | "url" => i["title_image_url"], 14 | }, 15 | "color" => 16777215, 16 | "fields" => [ 17 | %{ 18 | "name" => "Title", 19 | "value" => i["title"], 20 | "inline" => false 21 | }, 22 | %{ 23 | "name" => "Release Date", 24 | "value" => i["release_date"], 25 | "inline" => true 26 | }, 27 | %{ 28 | "name" => "Style Code", 29 | "value" => i["style_code"], 30 | "inline" => true 31 | }, 32 | %{ 33 | "name" => "Price", 34 | "value" => i["price"], 35 | "inline" => true, 36 | }, 37 | %{ 38 | "name" => "Last Sale", 39 | "value" => i["last_sale"], 40 | "inline" => true 41 | }, 42 | %{ 43 | "name" => "Color", 44 | "value" => i["color"], 45 | "inline" => true 46 | } 47 | ] 48 | } 49 | 50 | DiscordActions.send_embed_to_channel(embed, channel, token) 51 | _ -> 52 | DiscordActions.send_message_to_channel("Sorry, that product could not be found.", channel, token) 53 | end 54 | end 55 | end -------------------------------------------------------------------------------- /rel/config.exs: -------------------------------------------------------------------------------- 1 | # Import all plugins from `rel/plugins` 2 | # They can then be used by adding `plugin MyPlugin` to 3 | # either an environment, or release definition, where 4 | # `MyPlugin` is the name of the plugin module. 5 | ~w(rel plugins *.exs) 6 | |> Path.join() 7 | |> Path.wildcard() 8 | |> Enum.map(&Code.eval_file(&1)) 9 | 10 | use Mix.Releases.Config, 11 | # This sets the default release built by `mix release` 12 | default_release: :default, 13 | # This sets the default environment used by `mix release` 14 | default_environment: Mix.env() 15 | 16 | # For a full list of config options for both releases 17 | # and environments, visit https://hexdocs.pm/distillery/config/distillery.html 18 | 19 | 20 | # You may define one or more environments in this file, 21 | # an environment's settings will override those of a release 22 | # when building in that environment, this combination of release 23 | # and environment configuration is called a profile 24 | 25 | environment :dev do 26 | # If you are running Phoenix, you should make sure that 27 | # server: true is set and the code reloader is disabled, 28 | # even in dev mode. 29 | # It is recommended that you build with MIX_ENV=prod and pass 30 | # the --env flag to Distillery explicitly if you want to use 31 | # dev mode. 32 | set dev_mode: true 33 | set include_erts: false 34 | set cookie: :"Sp6HiRWW(X~S@/ws}2Z9;VgYuFo0H_2sQ&CNzguCswW99GbBA %{"discord_token" => token}} = bot_config, %{:data => %{"channel_id" => channel}} = discord_payload) do 7 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":control_knobs: Generating...", channel, token) 8 | 9 | case HTTPoison.post( 10 | "https://srs.adidas.com/scvRESTServices/account/createAccount", 11 | Poison.encode!(%{ 12 | "source" => "90901", 13 | "countryOfSite" => "GB", 14 | "email" => List.first(args), 15 | "clientId" => "293FC0ECC43A4F5804C07A4ABC2FC833", 16 | "password" => "Password123", 17 | "minAgeConfirmation" => "Y", 18 | "version" => "13.0", 19 | "access_token_manager_id" => "jwt", 20 | "scope" => "pii mobile2web", 21 | "actionType" => "REGISTRATION" 22 | }), 23 | [ 24 | {"Host", "srs.adidas.com"}, 25 | {"Accept", "application/json"}, 26 | {"Content-Type", "application/json"}, 27 | {"Accept-Language", "en-gb"}, 28 | {"User-Agent", "adidas/579 CFNetwork/894 Darwin/17.4.0"}, 29 | ]) do 30 | {_, %HTTPoison.Response{:body => b}} -> 31 | case Poison.decode!(b) do 32 | %{"conditionCodeParameter" => %{"parameter" => [%{"name" => "eUCI"} | _]}} -> 33 | embed = %{ 34 | "title" => "Adidas account generated", 35 | "description" => "#{List.first(args)}:Password123", 36 | "color" => 16777215 37 | } 38 | 39 | DiscordActions.edit_message_to_embed(embed, message_to_edit, channel, token) 40 | _ -> 41 | DiscordActions.edit_message(":x: Sorry, there has been an error. That email might already have an account.", message_to_edit, channel, token) 42 | end 43 | _ -> 44 | DiscordActions.edit_message(":x: Sorry, there has been an error.", message_to_edit, channel, token) 45 | end 46 | end 47 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/stockx_search/stockx_search.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.StockX do 2 | @behaviour DiscordGatewayGs.ModuleExecutor.DiscordActions 3 | 4 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 5 | 6 | def handle_command({"stockx", args}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{"channel_id" => channel}} = discord_payload) do 7 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":mag_right: Searching...", channel, token) 8 | 9 | query = args 10 | |> Enum.join(" ") 11 | |> URI.encode 12 | 13 | case HTTPoison.post("https://xw7sbct9v6-dsn.algolia.net/1/indexes/products/query", Poison.encode!(%{"params" => "query={'#{args}'}&hitsPerPage=1"}), [{"x-algolia-api-key", "6bfb5abee4dcd8cea8f0ca1ca085c2b3"}, {"x-algolia-application-id", "XW7SBCT9V6"}]) do 14 | {_, %HTTPoison.Response{:body => b}} -> 15 | case Poison.decode!(b) do 16 | %{"hits" => [i | _]} -> 17 | embed = %{ 18 | "thumbnail" => %{ 19 | "url" => i["thumbnail_url"], 20 | }, 21 | "color" => 8015747, 22 | "fields" => [ 23 | %{ 24 | "name" => "Name", 25 | "value" => i["name"], 26 | "inline" => false 27 | }, 28 | %{ 29 | "name" => "Style ID", 30 | "value" => i["style_id"], 31 | "inline" => true 32 | }, 33 | %{ 34 | "name" => "Highest Bid", 35 | "value" => "$#{i["highest_bid"]}", 36 | "inline" => true 37 | }, 38 | %{ 39 | "name" => "Lowest Ask", 40 | "value" => "$#{i["lowest_ask"]}", 41 | "inline" => true, 42 | }, 43 | %{ 44 | "name" => "Last Sale", 45 | "value" => "$#{i["last_sale"]}", 46 | "inline" => true 47 | } 48 | ] 49 | } 50 | 51 | DiscordActions.edit_message_to_embed(embed, message_to_edit, channel, token) 52 | _ -> 53 | DiscordActions.edit_message(":x: Sorry, that product could not be found.", message_to_edit, channel, token) 54 | end 55 | _ -> 56 | DiscordActions.edit_message("Sorry, that product could not be found.", message_to_edit, channel, token) 57 | end 58 | end 59 | end -------------------------------------------------------------------------------- /lib/discord_gateway_client/heartbeat.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.GatewayClient.Heartbeat do 2 | @moduledoc """ 3 | Heartbeat service for Discord websocket connection. 4 | Sends heartbeat on interval and detects stale connection if heartbeat ack 5 | is not received. 6 | """ 7 | use GenServer 8 | require Logger 9 | import DiscordGatewayGs.GatewayClient, only: [opcodes: 0] 10 | import DiscordGatewayGs.GatewayClient.Utility 11 | 12 | # API 13 | 14 | def start_link(agent_seq_num, interval, socket_pid, opts \\ []) do 15 | GenServer.start_link(__MODULE__, {agent_seq_num, interval, socket_pid}, opts) 16 | end 17 | 18 | def reset(pid) do 19 | GenServer.call(pid, :reset) 20 | end 21 | 22 | def ack(pid) do 23 | GenServer.call(pid, :ack) 24 | end 25 | 26 | # Server 27 | 28 | def init({agent_seq_num, interval, socket_pid}) do 29 | state = %{ 30 | agent_seq_num: agent_seq_num, 31 | interval: interval, 32 | socket_pid: socket_pid, 33 | timer: nil, 34 | ack?: true, 35 | } 36 | # initial beat with ack?=true 37 | send(self(), :beat) 38 | {:ok, state} 39 | end 40 | 41 | @doc "Heartbeat ACK has been received, sends new heartbeat down the wire" 42 | def handle_info(:beat, %{interval: interval, socket_pid: socket_pid, ack?: true} = state) do 43 | value = agent_value(state[:agent_seq_num]) 44 | payload = payload_build(opcode(opcodes(), :heartbeat), value) 45 | :websocket_client.cast(socket_pid, {:binary, payload}) 46 | timer = Process.send_after(self(), :beat, interval) 47 | {:noreply, %{state | ack?: false, timer: timer}} 48 | end 49 | 50 | @doc "Heartbeat ACK not received, connection is stale. Stop heartbeat." 51 | def handle_info(:beat, %{socket_pid: socket_pid, ack?: false} = state) do 52 | send(socket_pid, :heartbeat_stale) 53 | {:noreply, %{state | timer: nil}} 54 | end 55 | 56 | @doc "Receive heartbeat ACK" 57 | def handle_call(:ack, _from, state) do 58 | {:reply, :ok, %{state | ack?: true}} 59 | end 60 | 61 | @doc "Reset heartbeat" 62 | def handle_call(:reset, _from, %{timer: nil} = state) do 63 | send(self(), :beat) 64 | {:reply, :ok, %{state | ack?: true}} 65 | end 66 | def handle_call(:reset, _from, %{timer: timer} = state) do 67 | Process.cancel_timer(timer) 68 | send(self(), :beat) 69 | {:reply, :ok, %{state | ack?: true, timer: nil}} 70 | end 71 | 72 | def handle_call(msg, _from, state) do 73 | Logger.debug(fn -> "Heartbeat called with invalid message #{inspect msg}" end) 74 | {:noreply, state} 75 | end 76 | 77 | end -------------------------------------------------------------------------------- /lib/node_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.NodeManager do 2 | use GenServer 3 | 4 | defstruct identifier: "" 5 | 6 | def start_link do 7 | GenServer.start_link(__MODULE__, [], name: :local_node_manager) 8 | end 9 | 10 | def init(_) do 11 | Application.get_env(:discord_gateway_gs, :internal_api) |> IO.inspect 12 | :ets.new(:available_bots, [:set, :public, :named_table]) 13 | :ets.new(:bot_sessions, [:set, :public, :named_table]) 14 | :ets.new(:node_info, [:set, :protected, :named_table]) 15 | :ets.new(:modules, [:set, :public, :named_table]) 16 | :ets.new(:commands, [:set, :public, :named_table]) 17 | 18 | IO.puts("Node online!") 19 | 20 | identifier = case System.get_env("MY_POD_NAME") do 21 | nil -> "dev" 22 | other -> other 23 | end 24 | 25 | :ets.insert_new(:node_info, {"identifier", identifier}) 26 | 27 | load_modules() 28 | schedule_periodic_update() 29 | 30 | {:ok, %__MODULE__{identifier: identifier}} 31 | end 32 | 33 | # Callbacks 34 | 35 | def handle_info({:safely_terminate_bot, id}, state) do 36 | Horde.Supervisor.terminate_child(DiscordGatewayGs.DistributedSupervisor, "bot_" <> id) 37 | {:noreply, state} 38 | end 39 | 40 | def handle_info({:safely_restart_bot, id}, state) do 41 | [{pid, _}] = Horde.Registry.lookup(DiscordGatewayGs.GSRegistry, "bot_" <> id) 42 | gen_state = :sys.get_state(pid) 43 | 44 | Horde.Supervisor.terminate_child(DiscordGatewayGs.DistributedSupervisor, "bot_" <> id) 45 | Horde.Supervisor.start_child(DiscordGatewayGs.DistributedSupervisor, %{id: id, start: {DiscordGatewayGs.TestBotGS, :start_link, [gen_state]}}) 46 | {:noreply, state} 47 | end 48 | 49 | 50 | def handle_info(:do_firestore_update, state) do 51 | # TODO: only count matches of bot GenServers 52 | avail_bots_ets_info = :ets.info(:available_bots) 53 | DiscordGatewayGs.Statix.set_bots_running_gauge(avail_bots_ets_info[:size]) 54 | internal_api_host = Application.get_env(:discord_gateway_gs, :internal_api) 55 | HTTPoison.post("http://localhost:8888/nodes/health", Poison.encode!(%{"identifier" => state.identifier, "bots_running" => avail_bots_ets_info[:size], "messages_per_second" => 1}), [{"Content-Type", "application/json"}]) 56 | schedule_periodic_update() 57 | {:noreply, state} 58 | end 59 | 60 | # Internal API 61 | 62 | defp load_modules() do 63 | internal_api_host = Application.get_env(:discord_gateway_gs, :internal_api) 64 | {_, res} = HTTPoison.get("http://localhost:8888/modules/internal") 65 | modules = Poison.decode!(res.body)["modules"]; 66 | Enum.each(modules, fn m -> 67 | Enum.each(m["commands"], fn {c, i} -> 68 | i = Map.put(i, "module", m["id"]) 69 | :ets.insert_new(:commands, {c, i}) 70 | end) 71 | :ets.insert_new(:modules, {m["internal_reference"], m}) 72 | end) 73 | IO.puts inspect(modules) 74 | end 75 | 76 | defp schedule_periodic_update() do 77 | Process.send_after(self(), :do_firestore_update, 25000) 78 | end 79 | end -------------------------------------------------------------------------------- /lib/discord_gateway_client/utility.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.GatewayClient.Utility do 2 | @moduledoc """ 3 | Utilty methods to be used for discord clients. 4 | 5 | Normalizers, Encoders, and Decoders 6 | """ 7 | 8 | @doc "Convert atom to string" 9 | @spec normalize_atom(atom) :: String.t 10 | def normalize_atom(atom) do 11 | atom |> Atom.to_string |> String.downcase |> String.to_atom 12 | end 13 | 14 | @doc "Build a binary payload for discord communication" 15 | @spec payload_build(number, map, number, String.t) :: binary 16 | def payload_build(op, data, seq_num \\ nil, event_name \\ nil) do 17 | load = %{"op" => op, "d" => data} 18 | load 19 | |> _update_payload(seq_num, "s", seq_num) 20 | |> _update_payload(event_name, "t", seq_num) 21 | |> :erlang.term_to_binary 22 | end 23 | 24 | @doc "Build a json payload for discord communication" 25 | @spec payload_build_json(number, map, number, String.t) :: binary 26 | def payload_build_json(op, data, seq_num \\ nil, event_name \\ nil) do 27 | load = %{"op" => op, "d" => data} 28 | load 29 | |> _update_payload(seq_num, "s", seq_num) 30 | |> _update_payload(event_name, "t", seq_num) 31 | |> Poison.encode! 32 | end 33 | 34 | @doc "Decode binary payload received from discord into a map" 35 | @spec payload_decode(list, {atom, binary}) :: map 36 | def payload_decode(codes, {:binary, payload}) do 37 | payload = :erlang.binary_to_term(payload) 38 | %{op: opcode(codes, payload[:op] || payload["op"]), data: (payload[:d] || payload["d"]), seq_num: (payload[:s] || payload["s"]), event_name: (payload[:t] || payload["t"])} 39 | end 40 | 41 | @doc "Decode json payload received from discord into a map" 42 | @spec payload_decode(list, {atom, binary}) :: map 43 | def payload_decode(codes, {:text, payload}) do 44 | payload = Poison.decode!(payload) 45 | %{op: opcode(codes, payload[:op] || payload["op"]), data: (payload[:d] || payload["d"]), seq_num: (payload[:s] || payload["s"]), event_name: (payload[:t] || payload["t"])} 46 | end 47 | 48 | @doc "Get the integer value for an opcode using it's name" 49 | @spec opcode(map, atom) :: integer 50 | def opcode(codes, value) when is_atom(value) do 51 | codes[value] 52 | end 53 | 54 | @doc "Get the atom value of and opcode using an integer value" 55 | @spec opcode(map, integer) :: atom 56 | def opcode(codes, value) when is_integer(value) do 57 | {k, _value} = Enum.find codes, fn({_key, v}) -> v == value end 58 | k 59 | end 60 | 61 | @doc "Generic function for getting the value from an agent process" 62 | @spec agent_value(pid) :: any 63 | def agent_value(agent) do 64 | Agent.get(agent, fn a -> a end) 65 | end 66 | 67 | @doc "Generic function for updating the value of an agent process" 68 | @spec agent_update(pid, any) :: nil 69 | def agent_update(agent, n) do 70 | if n != nil do 71 | Agent.update(agent, fn _a -> n end) 72 | end 73 | end 74 | 75 | # Makes it easy to just update and pipe a payload 76 | defp _update_payload(load, var, key, value) do 77 | if var do 78 | Map.put(load, key, value) 79 | else 80 | load 81 | end 82 | end 83 | 84 | end -------------------------------------------------------------------------------- /lib/connectivity/lavalink/struct/player.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Struct.Player do 2 | alias LavaPotion.Struct.Node 3 | alias LavaPotion.Struct.Client 4 | 5 | defstruct [:node, :guild_id, :session_id, :token, :endpoint, :track, :volume, :is_real, :paused, :raw_timestamp, :raw_position] 6 | 7 | def initialize(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}) do 8 | WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:voice_update, player}) 9 | end 10 | 11 | def play(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}, track) when is_binary(track) do 12 | info = LavaPotion.Api.decode_track(track) 13 | WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:play, player, {track, info}}) 14 | end 15 | 16 | def play(player = %__MODULE__{node: %Node{address: old_address, client: %Client{user_id: user_id}}}, %{"track" => track, "info" => info = %{}}) do 17 | {:ok, node = %Node{address: address, client: %Client{user_id: user_id}}} = Node.best_node() 18 | if old_address !== address do 19 | set_node(player, node) 20 | WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:play, player, {track, info}}) 21 | else 22 | WebSockex.cast(Node.pid("#{user_id}_#{old_address}"), {:play, player, {track, info}}) 23 | end 24 | end 25 | 26 | def volume(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}, volume) when is_number(volume) and volume >= 0 and volume <= 1000 do 27 | WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:volume, player, volume}) 28 | end 29 | 30 | def seek(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}, position) when is_number(position) and position >= 0 do 31 | WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:seek, player, position}) 32 | end 33 | 34 | def pause(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}), do: WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:pause, player, true}) 35 | def resume(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}), do: WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:pause, player, false}) 36 | def destroy(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}), do: WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:destroy, player}) 37 | def stop(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}}), do: WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:stop, player}) 38 | 39 | def position(player = %__MODULE__{node: %Node{}, raw_position: raw_position, raw_timestamp: raw_timestamp}) 40 | when not is_nil(raw_position) and not is_nil(raw_timestamp) do 41 | %__MODULE__{paused: paused, track: {_, %{"length" => length}}} = player 42 | if paused do 43 | min(raw_position, length) 44 | else 45 | min(raw_position + (:os.system_time(:millisecond) - raw_timestamp), length) 46 | end 47 | end 48 | 49 | def set_node(player = %__MODULE__{node: %Node{address: address, client: %Client{user_id: user_id}}, is_real: true}, node = %Node{}) do 50 | WebSockex.cast(Node.pid("#{user_id}_#{address}"), {:update_node, player, node}) 51 | end 52 | end -------------------------------------------------------------------------------- /lib/connectivity/lavalink/stage/middle.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Stage.Middle do 2 | use GenStage 3 | 4 | alias LavaPotion.Stage.Producer 5 | alias LavaPotion.Struct.{Player, Stats} 6 | 7 | require Logger 8 | 9 | @ets_lookup :lavapotion_ets_table 10 | 11 | def start_link() do 12 | GenStage.start_link(__MODULE__, :ok, name: __MODULE__) 13 | end 14 | 15 | def init(state) do 16 | {:producer_consumer, state, subscribe_to: [Producer]} 17 | end 18 | 19 | def handle_events(events, _from, state) do 20 | events = events 21 | |> Enum.map( 22 | fn data = %{"op" => opcode} -> 23 | handle(opcode, data) 24 | end 25 | ) 26 | |> Enum.filter(&(&1 !== :ignore)) 27 | {:noreply, events, state} 28 | end 29 | 30 | def handle("playerUpdate", %{"host" => host, "bot_id" => bot_id, "guildId" => guild_id, "state" => %{"time" => timestamp, "position" => position}}) do 31 | [{_, map = %{players: players = %{^guild_id => player = %Player{}}}}] = :ets.lookup(@ets_lookup, "#{bot_id}_#{host}") 32 | players = Map.put(players, guild_id, %Player{player | raw_position: position, raw_timestamp: timestamp}) 33 | :ets.insert(@ets_lookup, {"#{bot_id}_#{host}", %{map | players: players}}) 34 | :ignore 35 | end 36 | 37 | def handle("stats", data = %{"host" => host, "bot_id" => bot_id, "players" => players, "playingPlayers" => playing_players, "uptime" => uptime, 38 | "memory" => memory, "cpu" => cpu}) do 39 | frame_stats = data["frameStats"] # might not exist in map so can't match 40 | stats = %Stats{players: players, playing_players: playing_players, uptime: uptime, memory: memory, cpu: cpu, frame_stats: frame_stats} 41 | [{_, map}] = :ets.lookup(@ets_lookup, "#{bot_id}_#{host}") 42 | :ets.insert(@ets_lookup, {"#{bot_id}_#{host}", %{map | stats: stats}}) 43 | :ignore 44 | end 45 | 46 | def handle("event", data = %{"host" => host, "bot_id" => bot_id, "type" => type, "guildId" => guild_id}) do 47 | case type do 48 | "TrackEndEvent" -> 49 | n = :ets.lookup(@ets_lookup, "#{bot_id}_#{host}") 50 | if match?([{_, map = %{players: players = %{^guild_id => player = %Player{}}}}], n) do 51 | [{_, map = %{players: players = %{^guild_id => player = %Player{}}}}] = n 52 | players = Map.put(players, guild_id, %Player{player | track: nil}) 53 | :ets.insert(@ets_lookup, {"#{bot_id}_#{host}", %{map | players: players}}) 54 | [:track_end, {host, bot_id, guild_id, data["track"], data["reason"]}] # list for use in Kernel.apply/3 55 | end 56 | "TrackExceptionEvent" -> 57 | error = data["error"] 58 | Logger.error "Error in Player for Guild ID: #{guild_id} | Message: #{error}" 59 | [:track_exception, {host, bot_id, guild_id, data["track"], error}] 60 | 61 | "TrackStuckEvent" -> 62 | Logger.warn "Track stuck for Player/Guild ID: #{guild_id}" 63 | [:track_stuck, {host, bot_id, guild_id, data["track"]}] 64 | 65 | "WebSocketClosedEvent" -> 66 | code = data["code"] 67 | reason = data["reason"] 68 | Logger.warn "Audio WebSocket Connection to Discord closed for Guild ID: #{guild_id}, Code: #{code}, Reason: #{reason}" 69 | [:websocket_closed, {host, bot_id, guild_id, code, reason}] 70 | _ -> 71 | :ignore 72 | end 73 | end 74 | 75 | def handle(_op, data), do: data # warning in consumer 76 | end -------------------------------------------------------------------------------- /lib/module_executor/module_center.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.ModuleCenter do 2 | use Bitwise 3 | 4 | alias DiscordGatewayGs.ModuleExecutor.DiscordPermission 5 | 6 | @spec handle_moduled_command(tuple(), String.t(), tuple()) :: none() 7 | def handle_moduled_command({attempted_command, args} = command, command_info, {bot_config, discord_payload} = data) do 8 | GenServer.cast(:local_redis_client, {:publish, "cc-realtime-events", %{"action" => "command_executed", "data" => %{"creator": bot_config["creator"], "bot_id": bot_config["id"], "command_executed": attempted_command, author: Map.put(discord_payload.data["author"], "id", Integer.to_string(discord_payload.data["author"]["id"]))}}}) 9 | %{"min_args" => min_args} = command_info 10 | module_config = bot_config["modules"][command_info["module"]]["config"] 11 | 12 | command_permissions = case module_config do 13 | %{"command_permissions" => %{^attempted_command => permissions}} -> 14 | permissions 15 | _ -> 16 | %{"type" => "everyone"} 17 | end 18 | 19 | case check_permissions(command_permissions, discord_payload) do 20 | {true, _} -> 21 | case length(args) do 22 | a when a >= min_args -> 23 | DiscordGatewayGs.ModuleExecutor.ModuleMap.modules[command_info["module"]].handle_command({attempted_command, args}, bot_config, discord_payload) 24 | _ -> 25 | command_usage = command_info["usage"] 26 | |> String.replace("(command)", bot_config["interface"]["command_prefix"] <> attempted_command) 27 | 28 | DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions.send_message_to_channel( 29 | "Invalid command usage!\nUsage: `#{command_usage}`", 30 | discord_payload.data["channel_id"], 31 | bot_config["authorization"]["discord_token"] 32 | ) 33 | end 34 | {false, msg} -> 35 | DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions.send_message_to_channel( 36 | ":x: #{msg}", 37 | discord_payload.data["channel_id"], 38 | bot_config["authorization"]["discord_token"] 39 | ) 40 | end 41 | end 42 | 43 | defp check_permissions(permissions_obj, payload) do 44 | case permissions_obj["type"] do 45 | "everyone" -> 46 | {true, ""} 47 | "named_role" -> 48 | member = payload.data["member"] 49 | #{_, [_, roles]} = GenServer.call :local_redis_client, {:custom, ["HSCAN", payload.data.guild_id, "0", "MATCH", "role:*", "COUNT", "1000"]} 50 | 51 | roles = member.roles 52 | |> Enum.map(fn r -> 53 | {_, role} = GenServer.call :local_redis_client, {:hget, payload.data.guild_id, "role:#{r}"} 54 | Poison.decode!(role) 55 | end) 56 | 57 | if Enum.any?(roles, fn r -> String.downcase(r["name"]) == String.downcase(permissions_obj["named_role"]) end) || member_has_admin?(roles) do 58 | {true, ""} 59 | else 60 | {false, "You need to be admin or have a role named `#{permissions_obj["named_role"]}` to do that."} 61 | end 62 | _ -> {true, ""} 63 | end 64 | end 65 | 66 | defp member_has_admin?(member_roles) do 67 | member_permissions = 68 | member_roles 69 | |> Enum.reduce(0, fn role, bitset_acc -> 70 | bitset_acc ||| role["permissions"] 71 | end) 72 | |> DiscordPermission.from_bitset() 73 | |> Enum.member?(:administrator) 74 | end 75 | end -------------------------------------------------------------------------------- /lib/module_executor/actions/discord_actions.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions do 2 | 3 | @spec send_message_to_channel(String.t(), String.t(), String.t()) :: {:ok} | nil 4 | def send_message_to_channel(message, channel, token) do 5 | message = message 6 | |> String.replace(~r/@(everyone|here)/, ~s"@\u200beveryone") 7 | 8 | {_, %HTTPoison.Response{:body => b}} = HTTPoison.post("https://discordapp.com/api/v6/channels/#{channel}/messages", Poison.encode!(%{"content" => message}), [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 9 | 10 | discord_message = b 11 | |> Poison.decode! 12 | 13 | {:ok, discord_message} 14 | end 15 | 16 | def edit_message(new_content, message_id, channel, token) do 17 | new_content = new_content 18 | |> String.replace(~r/@(everyone|here)/, ~s"@\u200beveryone") 19 | 20 | {_, %HTTPoison.Response{:body => b}} = HTTPoison.patch("https://discordapp.com/api/v6/channels/#{channel}/messages/#{message_id}", Poison.encode!(%{"content" => new_content}), [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 21 | 22 | discord_message = b 23 | |> Poison.decode! 24 | 25 | {:ok, discord_message} 26 | end 27 | 28 | def edit_message_to_embed(new_embed, message_id, channel, token) do 29 | {_, %HTTPoison.Response{:body => b}} = HTTPoison.patch("https://discordapp.com/api/v6/channels/#{channel}/messages/#{message_id}", Poison.encode!(%{"content" => "", "embed" => new_embed}), [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 30 | 31 | discord_message = b 32 | |> Poison.decode! 33 | 34 | {:ok, discord_message} 35 | end 36 | 37 | def send_embed_to_channel(embed, channel, token) do 38 | {_, %HTTPoison.Response{:body => b}} = HTTPoison.post("https://discordapp.com/api/v6/channels/#{channel}/messages", Poison.encode!(%{"embed" => embed}), [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 39 | 40 | {:ok, (b |> Poison.decode!)} 41 | end 42 | 43 | def ban_user(user_id, guild_id, token, reason? \\ "") do 44 | {_, resp} = HTTPoison.put("https://discordapp.com/api/v6/guilds/#{guild_id}/bans/#{user_id}?reason=#{reason?}", "", [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 45 | 46 | case resp do 47 | %HTTPoison.Response{:status_code => 204} -> {:ok} 48 | %HTTPoison.Response{:body => b} -> {:error, Poison.decode!(b)} 49 | end 50 | end 51 | 52 | def unban_user(user_id, guild_id, token) do 53 | {_, resp} = HTTPoison.delete("https://discordapp.com/api/v6/guilds/#{guild_id}/bans/#{user_id}", [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 54 | 55 | case resp do 56 | %HTTPoison.Response{:status_code => 204} -> {:ok} 57 | %HTTPoison.Response{:body => b} -> {:error, Poison.decode!(b)} 58 | end 59 | end 60 | 61 | def kick_user(user_id, guild_id, token) do 62 | {_, resp} = HTTPoison.delete("https://discordapp.com/api/v6/guilds/#{guild_id}/members/#{user_id}", [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 63 | 64 | case resp do 65 | %HTTPoison.Response{:status_code => 204} -> {:ok} 66 | %HTTPoison.Response{:body => b} -> {:error, Poison.decode!(b)} 67 | end 68 | end 69 | 70 | def purge_messages(message_ids, channel_id, token) do 71 | {_, resp} = HTTPoison.post("https://discordapp.com/api/v6/channels/#{channel_id}/messages/bulk-delete", Poison.encode!(%{"messages" => message_ids}), [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 72 | 73 | case resp do 74 | %HTTPoison.Response{:status_code => 204} -> {:ok} 75 | %HTTPoison.Response{:body => b} -> {:error, Poison.decode!(b)} 76 | end 77 | end 78 | 79 | def fetch_messages(channel_id, token, limit? \\ 100, before? \\ "") do 80 | {_, %HTTPoison.Response{:body => b}} = HTTPoison.get("https://discordapp.com/api/v6/channels/#{channel_id}/messages?limit=#{limit?}&before=#{before?}", [{"Authorization", "Bot " <> token}, {"Content-Type", "application/json"}]) 81 | 82 | case Poison.decode!(b) do 83 | m when is_list(m) -> {:ok, m} 84 | _ -> {:error} 85 | end 86 | end 87 | 88 | end -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "artificery": {:hex, :artificery, "0.4.0", "e0b8d3eb9dfe8f42c08a620f90a2aa9cef5dba9fcdfcecad5c2be451df159a77", [:mix], [], "hexpm"}, 3 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "delta_crdt": {:hex, :delta_crdt, "0.3.1", "a50bff0460da2d9b0559a68c0116bf8f0c3847de272bdce4a391aae2063902d3", [:mix], [], "hexpm"}, 5 | "distillery": {:hex, :distillery, "2.0.12", "6e78fe042df82610ac3fa50bd7d2d8190ad287d120d3cd1682d83a44e8b34dfb", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "gen_stage": {:hex, :gen_stage, "0.14.1", "9d46723fda072d4f4bb31a102560013f7960f5d80ea44dcb96fd6304ed61e7a4", [:mix], [], "hexpm"}, 7 | "hackney": {:hex, :hackney, "1.15.0", "287a5d2304d516f63e56c469511c42b016423bcb167e61b611f6bad47e3ca60e", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "horde": {:hex, :horde, "0.4.0-rc.2", "06b2c992f5f7cb36beb64e08f0ef95699c40321c336ae44ca52043793138ac0f", [:mix], [{:delta_crdt, "~> 0.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:xxhash, "~> 0.1", [hex: :xxhash, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "httpoison": {:hex, :httpoison, "1.5.0", "71ae9f304bdf7f00e9cd1823f275c955bdfc68282bc5eb5c85c3a9ade865d68e", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 11 | "instruments": {:hex, :instruments, "1.1.1", "e76b283bef5c541145caae2c60a53157dcf0c970dfe66618f3ed3e61d233e205", [:mix], [{:recon, "~> 2.3.1", [hex: :recon, repo: "hexpm", optional: false]}, {:statix, "~> 1.0.1", [hex: :statix, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 13 | "libcluster": {:hex, :libcluster, "3.0.3", "492e98c7f5c9a6e95b8d51f0b198cf8eab60af3b490f40b958d4bc326d11e40e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 15 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, 16 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 17 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, 18 | "recon": {:hex, :recon, "2.3.6", "2bcad0cf621fb277cabbb6413159cd3aa30265c2dee42c968697988b30108604", [:rebar3], [], "hexpm"}, 19 | "redix": {:hex, :redix, "0.9.1", "7a97d04cef2d88a5042cdb911ee9f2a67fa960c75db2beec6947acbd89a0151d", [:mix], [], "hexpm"}, 20 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 21 | "statix": {:hex, :statix, "1.0.1", "04600d22f44456544cf8b7363b3e3872419b0e0db898d7c373eef098ac7f1200", [:mix], [], "hexpm"}, 22 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 23 | "websocket_client": {:hex, :websocket_client, "1.2.4", "14ec1ca4b6d247b44ccd9a80af8f6ca98328070f6c1d52a5cb00bc9d939d63b8", [:rebar3], [], "hexpm"}, 24 | "websockex": {:hex, :websockex, "0.4.2", "9a3b7dc25655517ecd3f8ff7109a77fce94956096b942836cdcfbc7c86603ecc", [:mix], [], "hexpm"}, 25 | "xxhash": {:hex, :xxhash, "0.2.1", "ab0893a8124f3c11116c57e500485dc5f67817d1d4c44f0fff41f3fd3c590607", [:mix], [], "hexpm"}, 26 | } 27 | -------------------------------------------------------------------------------- /lib/discord_gateway_client/redis_sync.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.RedisSync do 2 | use GenServer 3 | alias DiscordGatewayGs.Structs 4 | 5 | def start_link(state) do 6 | GenServer.start_link(__MODULE__, state, name: via_tuple(Integer.to_string(state))) 7 | end 8 | 9 | def init(guild) do 10 | schedule_key_expiry 11 | {:ok, %{id: guild}} 12 | end 13 | 14 | def send_guild_payload(guild_id, guild) do 15 | GenServer.cast via_tuple(guild_id), {:send_guild_payload, guild} 16 | end 17 | 18 | def guild_create(guild) do 19 | guild_state = struct(Structs.GuildState, guild) 20 | channels = guild.channels 21 | |> Enum.map(fn channel -> 22 | struct(Structs.ChannelState, channel) 23 | end) 24 | members = guild.members 25 | |> Enum.map(fn member -> 26 | voice? = guild.voice_states 27 | |> Enum.find(fn vc -> (vc.user_id == member.user.id) end) 28 | || nil 29 | 30 | member = Map.put(member, :voice, voice?) 31 | 32 | struct(Structs.MemberState, member) 33 | end) 34 | roles = guild.roles 35 | |> Enum.map(fn role -> 36 | struct(Structs.RoleState, role) 37 | end) 38 | 39 | DiscordGatewayGs.RedisConnector.insert_guild(guild_state, channels, members, roles) 40 | end 41 | 42 | def guild_member_add(%{data: data} = payload) do 43 | atomic_data = data |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) 44 | 45 | member = struct(Structs.MemberState, atomic_data) 46 | 47 | GenServer.cast :local_redis_client, {:custom, ["HSET", Integer.to_string(data["guild_id"]), "member:" <> Integer.to_string(member.user["id"]), Poison.encode!(member)]} 48 | end 49 | 50 | def guild_member_remove(%{data: data} = payload) do 51 | GenServer.cast :local_redis_client, {:hdel, Integer.to_string(data["guild_id"]), Integer.to_string(data["user"]["id"])} 52 | end 53 | 54 | def guild_member_update(%{data: data} = payload) do 55 | {_, member} = GenServer.call :local_redis_client, {:hget, Integer.to_string(data["guild_id"]), "member:" <> Integer.to_string(data["user"]["id"])} 56 | member = member |> Poison.decode! 57 | 58 | new_member = member 59 | |> Map.put(:nick, data["nick"]) 60 | |> Map.put(:roles, data["roles"]) 61 | |> Map.put(:user, data["user"]) 62 | |> Map.put(:voice, member["voice"]) 63 | 64 | new_member = struct(Structs.MemberState, new_member) 65 | 66 | GenServer.cast :local_redis_client, {:custom, ["HSET", Integer.to_string(data["guild_id"]), "member:" <> Integer.to_string(new_member.user["id"]), Poison.encode!(new_member)]} 67 | end 68 | 69 | def guild_role_create_or_update(%{data: data} = payload) do 70 | atomic_data = data["role"] |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) 71 | 72 | new_role = struct(Structs.RoleState, atomic_data) 73 | 74 | GenServer.cast :local_redis_client, {:custom, ["HSET", Integer.to_string(data["guild_id"]), "role:" <> Integer.to_string(new_role.id), Poison.encode!(new_role)]} 75 | end 76 | 77 | def voice_state_update(%{data: data} = payload) do 78 | {_, member} = GenServer.call :local_redis_client, {:hget, Integer.to_string(data.guild_id), "member:" <> Integer.to_string(data.user_id)} 79 | member = member |> Poison.decode!(as: %Structs.MemberState{}) 80 | 81 | new_member = case data do 82 | %{:channel_id => nil} -> 83 | Map.merge(member, %{voice: nil}) 84 | _ -> 85 | m = data["member"] 86 | data = Map.delete(data, "member") 87 | m = Map.put(m, :voice, data) 88 | struct(Structs.MemberState, m) 89 | end 90 | 91 | GenServer.cast :local_redis_client, {:custom, ["HSET", Integer.to_string(data.guild_id), "member:" <> Integer.to_string(member.user["id"]), Poison.encode!(new_member)]} 92 | end 93 | 94 | 95 | def handle_cast({:send_guild_payload, payload}, state) do 96 | 97 | end 98 | 99 | def handle_info(:set_key_expiry, state) do 100 | schedule_key_expiry() 101 | GenServer.cast :local_redis_client, {:expire, Integer.to_string(state.id), 600} 102 | {:noreply, state} 103 | end 104 | 105 | defp schedule_key_expiry do 106 | Process.send_after self(), :set_key_expiry, 500000 107 | end 108 | 109 | defp via_tuple(name), do: {:via, Horde.Registry, {DiscordGatewayGs.GSRegistry, name}} 110 | end -------------------------------------------------------------------------------- /lib/module_executor/command_center.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.CommandCenter2 do 2 | 3 | alias DiscordGatewayGs.ModuleExecutor.Checks 4 | 5 | def static_variable_map do 6 | %{ 7 | "author:id" => {"discord_payload", &is_binary/1}, 8 | "author:username" => {"discord_payload", &is_binary/1}, 9 | } 10 | end 11 | 12 | def handle_command(payload, {bot_token, bot_id} = bot_data) do 13 | IO.puts "start lookup" 14 | if !payload.data["author"]["bot"] do 15 | Task.start fn -> 16 | GenServer.cast :local_redis_client, {:publish, "cc-messages", Poison.encode!( 17 | %{ 18 | "content" => payload.data["content"], 19 | "author" => Map.merge(payload.data["author"], %{"id" => Integer.to_string(payload.data["author"]["id"])}) 20 | } 21 | )} 22 | end 23 | [{id, %{"config" => bot_config}}] = :ets.lookup(:available_bots, Integer.to_string(bot_id)) 24 | if String.starts_with?(payload.data["content"], bot_config["interface"]["command_prefix"]) do 25 | [attempted_command | args] = payload.data["content"] |> String.to_charlist() |> tl() |> to_string() |> String.split(" ") 26 | 27 | custom? = Map.get(bot_config["custom_commands"], attempted_command) 28 | 29 | if custom? != nil do 30 | handle_custom_command({attempted_command, args}, custom?["actions"], bot_config, payload) 31 | else 32 | case :ets.lookup(:commands, attempted_command) do 33 | [{name, info}] -> 34 | if Map.has_key?(bot_config["modules"], info["module"]) do 35 | case bot_config["modules"][info["module"]] do 36 | %{"config" => %{"disabled_commands" => dc}} -> 37 | if(!Enum.member?(dc, attempted_command)) do 38 | DiscordGatewayGs.ModuleExecutor.ModuleCenter.handle_moduled_command({attempted_command, args}, info, {bot_config, payload}) 39 | end 40 | _ -> 41 | DiscordGatewayGs.ModuleExecutor.ModuleCenter.handle_moduled_command({attempted_command, args}, info, {bot_config, payload}) 42 | end 43 | end 44 | _ -> IO.puts "no_match" 45 | end 46 | end 47 | end 48 | end 49 | end 50 | 51 | 52 | # -=-=-=-=-=-=-=-=-=-=- 53 | # PRIVATE FUNCTIONS 54 | # -=-=-=-=-=-=-=-=-=-=- 55 | 56 | defp find_var_tokens(message) do 57 | Regex.scan(~r/\<.*?\>/, message) 58 | |> Enum.map(fn t -> 59 | t 60 | |> Enum.at(0) 61 | end) 62 | end 63 | 64 | defp strip_tokens_to_vars(tokens, {bot_config, discord_payload}) do 65 | final_outputs = [] 66 | 67 | tokens = tokens 68 | |> Enum.map(fn t -> 69 | t 70 | |> String.replace("<", "") 71 | |> String.replace(">", "") 72 | end) 73 | 74 | m = %{ 75 | "discord_payload" => discord_payload, 76 | "bot_config" => bot_config 77 | } 78 | 79 | Enum.map(tokens, fn token -> 80 | {required, check} = static_variable_map[token] 81 | case token do 82 | "author:id" -> 83 | m[required].data["author"]["id"] 84 | "author:username" -> 85 | m[required].data["author"]["username"] 86 | end 87 | end) 88 | end 89 | 90 | # 91 | 92 | defp handle_custom_command({command, args}, actions, bot_config, discord_payload) do 93 | GenServer.cast(:local_redis_client, {:publish, "cc-realtime-events", %{"action" => "command_executed", "data" => %{"creator": bot_config["creator"], "bot_id": bot_config["id"], "command_executed": command, author: discord_payload.data["author"]}}}) 94 | Enum.each(actions, fn action -> 95 | [{action, info}] = action |> Map.to_list 96 | action_function = DiscordGatewayGs.ModuleExecutor.Actions.ActionMap.actions[action] 97 | 98 | case action do 99 | "SEND_MESSAGE_TO_CHANNEL" -> 100 | tokens = info["message"] 101 | |> find_var_tokens 102 | 103 | if length(tokens) > 0 do 104 | compiled = tokens 105 | |> strip_tokens_to_vars({bot_config, discord_payload}) 106 | 107 | final_writable_message = tokens |> Enum.with_index(0) |> Enum.reduce(info["message"], fn {t, i}, acc -> String.replace(acc, t, Enum.at(compiled, i)) end) 108 | 109 | action_function.(final_writable_message, discord_payload.data["channel_id"], bot_config["authorization"]["discord_token"]) 110 | else 111 | action_function.(info["message"], discord_payload.data["channel_id"], bot_config["authorization"]["discord_token"]) 112 | end 113 | "HTTP_POST" -> 114 | action_function.(info, discord_payload, bot_config, "command:#{command}") 115 | end 116 | end) 117 | end 118 | end -------------------------------------------------------------------------------- /lib/module_executor/discord_permission.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.DiscordPermission do 2 | @moduledoc """ 3 | Functions that work on permissions. 4 | 5 | Some functions return a list of permissions. You can use enumerable functions 6 | to work with permissions: 7 | """ 8 | 9 | use Bitwise 10 | 11 | @typedoc """ 12 | Represents a single permission as a bitvalue. 13 | """ 14 | @type bit :: non_neg_integer 15 | 16 | @typedoc """ 17 | Represents a set of permissions as a bitvalue. 18 | """ 19 | @type bitset :: non_neg_integer 20 | 21 | @type general_permission :: 22 | :create_instant_invite 23 | | :kick_members 24 | | :ban_members 25 | | :administrator 26 | | :manage_channels 27 | | :manage_guild 28 | | :view_audit_log 29 | | :view_channel 30 | | :change_nickname 31 | | :manage_nicknames 32 | | :manage_roles 33 | | :manage_webhooks 34 | | :manage_emojis 35 | 36 | @type text_permission :: 37 | :add_reactions 38 | | :send_messages 39 | | :send_tts_messages 40 | | :manage_messages 41 | | :embed_links 42 | | :attach_files 43 | | :read_message_history 44 | | :mention_everyone 45 | | :use_external_emojis 46 | 47 | @type voice_permission :: 48 | :connect 49 | | :speak 50 | | :mute_members 51 | | :deafen_members 52 | | :move_members 53 | | :use_vad 54 | | :priority_speaker 55 | 56 | @type t :: 57 | general_permission 58 | | text_permission 59 | | voice_permission 60 | 61 | @permission_to_bit_map %{ 62 | create_instant_invite: 0x00000001, 63 | kick_members: 0x00000002, 64 | ban_members: 0x00000004, 65 | administrator: 0x00000008, 66 | manage_channels: 0x00000010, 67 | manage_guild: 0x00000020, 68 | add_reactions: 0x00000040, 69 | view_audit_log: 0x00000080, 70 | priority_speaker: 0x00000100, 71 | view_channel: 0x00000400, 72 | send_messages: 0x00000800, 73 | send_tts_messages: 0x00001000, 74 | manage_messages: 0x00002000, 75 | embed_links: 0x00004000, 76 | attach_files: 0x00008000, 77 | read_message_history: 0x00010000, 78 | mention_everyone: 0x00020000, 79 | use_external_emojis: 0x00040000, 80 | connect: 0x00100000, 81 | speak: 0x00200000, 82 | mute_members: 0x00400000, 83 | deafen_members: 0x00800000, 84 | move_members: 0x01000000, 85 | use_vad: 0x02000000, 86 | change_nickname: 0x04000000, 87 | manage_nicknames: 0x08000000, 88 | manage_roles: 0x10000000, 89 | manage_webhooks: 0x20000000, 90 | manage_emojis: 0x40000000 91 | } 92 | 93 | @bit_to_permission_map Map.new(@permission_to_bit_map, fn {k, v} -> {v, k} end) 94 | @permission_list Map.keys(@permission_to_bit_map) 95 | 96 | @doc """ 97 | Returns `true` if `term` is a permission; otherwise returns `false`. 98 | """ 99 | defguard is_permission(term) when is_atom(term) and term in @permission_list 100 | 101 | @doc """ 102 | Returns a list of all permissions. 103 | """ 104 | @spec all() :: [t] 105 | def all, do: @permission_list 106 | 107 | @doc """ 108 | Converts the given bit to a permission. 109 | 110 | This function returns `:error` if `bit` does not map to a permission. 111 | """ 112 | @spec from_bit(bit) :: {:ok, t} | :error 113 | def from_bit(bit) do 114 | Map.fetch(@bit_to_permission_map, bit) 115 | end 116 | 117 | @doc """ 118 | Same as `from_bit/1`, but raises `ArgumentError` in case of failure. 119 | 120 | ## Examples 121 | """ 122 | @spec from_bit!(bit) :: t 123 | def from_bit!(bit) do 124 | case from_bit(bit) do 125 | {:ok, perm} -> perm 126 | :error -> raise(ArgumentError, "expected a valid bit, got: `#{inspect(bit)}`") 127 | end 128 | end 129 | 130 | @doc """ 131 | Converts the given bitset to a list of permissions. 132 | 133 | If invalid bits are given they will be omitted from the results. 134 | """ 135 | @spec from_bitset(bitset) :: [t] 136 | def from_bitset(bitset) do 137 | 0..53 138 | |> Enum.map(fn index -> 0x1 <<< index end) 139 | |> Enum.filter(fn mask -> (bitset &&& mask) === mask end) 140 | |> Enum.reduce([], fn bit, acc -> 141 | case from_bit(bit) do 142 | {:ok, perm} -> [perm | acc] 143 | :error -> acc 144 | end 145 | end) 146 | end 147 | 148 | @doc """ 149 | Converts the given permission to a bit. 150 | """ 151 | @spec to_bit(t) :: bit 152 | def to_bit(permission) when is_permission(permission), do: @permission_to_bit_map[permission] 153 | 154 | @doc """ 155 | Converts the given enumerable of permissions to a bitset. 156 | ``` 157 | """ 158 | @spec to_bitset(Enum.t()) :: bitset 159 | def to_bitset(permissions) do 160 | permissions 161 | |> Enum.map(&to_bit(&1)) 162 | |> Enum.reduce(fn bit, acc -> acc ||| bit end) 163 | end 164 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/music/lavalink_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.Music.LavalinkManager do 2 | defstruct [:bot_id, :default_client, :node, :node_pid, :volume] 3 | use GenServer 4 | 5 | def start_link(state) do 6 | GenServer.start_link(__MODULE__, state, name: {:via, Horde.Registry, {DiscordGatewayGs.GSRegistry, "lavalink_" <> state.bot_id}}) 7 | end 8 | 9 | def init(state) do 10 | LavaPotion.start 11 | 12 | c = LavaPotion.Struct.Client.new(%{:user_id => state.bot_id, :default_port => 80}) 13 | n = LavaPotion.Struct.Node.new(%{:client => c, :address => "34.74.87.236"}) 14 | {_, nodepid} = LavaPotion.Struct.Node.start_link(n) 15 | 16 | {:ok, %__MODULE__{bot_id: state.bot_id, default_client: c, node: n, node_pid: nodepid, volume: 100}} 17 | end 18 | 19 | # 20 | # GenServer API 21 | # 22 | 23 | # Setters 24 | 25 | def initialize_guild(bot_id, guild, session, token, endpoint) do 26 | GenServer.cast via_tuple(bot_id), {:init_guild, %{guild: guild, session: session, token: token, endpoint: endpoint}} 27 | end 28 | 29 | def destroy_guild_voice(bot_id, guild) do 30 | GenServer.cast via_tuple(bot_id), {:destroy_guild_voice, guild} 31 | end 32 | 33 | def play_track(bot_id, guild, track) do 34 | GenServer.cast via_tuple(bot_id), {:play_track, %{guild: guild, track: track}} 35 | end 36 | 37 | def set_volume(bot_id, guild, volume) do 38 | GenServer.cast via_tuple(bot_id), {:set_volume, %{guild: guild, volume: volume}} 39 | end 40 | 41 | # Getters 42 | 43 | def get_volume(bot_id, guild) do 44 | GenServer.call via_tuple(bot_id), {:get_volume, guild} 45 | end 46 | 47 | def guild_is_playing?(bot_id, guild) do 48 | player? = GenServer.call via_tuple(bot_id), {:player_exists?, guild} 49 | 50 | Kernel.match?(%LavaPotion.Struct.Player{track: {_, _}}, player?) 51 | end 52 | 53 | def guild_player_presence?(bot_id, guild_id, channel_id, token) do 54 | case Horde.Registry.lookup(DiscordGatewayGs.GSRegistry, "lavalink_#{bot_id}") do 55 | [{pid, _}] -> 56 | case GenServer.call(pid, {:player_exists?, guild_id}) do 57 | %LavaPotion.Struct.Player{} -> {:ok, pid} 58 | _ -> 59 | Task.start fn -> 60 | DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions.send_message_to_channel(":x: I need to be in a voice channel to do that. Use `!join`", channel_id, token) 61 | end 62 | {:error, "Guild does not have a player"} 63 | end 64 | _ -> 65 | Task.start fn -> 66 | DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions.send_message_to_channel(":x: I need to be in a voice channel to do that. Use `!join`", channel_id, token) 67 | end 68 | {:error, "Lavalink manager for bot #{bot_id} does not exist."} 69 | end 70 | end 71 | 72 | # LavaPotion Event Callbacks 73 | 74 | def handle_track_event(event, {_host, bot_id, guild_id, _, _} = state) do 75 | if event == :track_end do 76 | DiscordGatewayGs.ModuleExecutor.Modules.Music.play_next_in_queue(bot_id, guild_id) 77 | end 78 | {:ok, state} 79 | end 80 | 81 | # 82 | # GenServer Callbacks 83 | # 84 | 85 | def handle_cast({:set_volume, %{:guild => guild, :volume => volume}}, state) do 86 | player = LavaPotion.Struct.Node.player(state.node, guild) 87 | 88 | LavaPotion.Struct.Player.volume(player, volume) 89 | {:noreply, %{state | volume: volume}} 90 | end 91 | 92 | def handle_cast({:init_guild, %{:guild => guild, :session => session, :token => token, :endpoint => endpoint} = opts}, state) do 93 | LavaPotion.Api.initialize(state.node_pid, guild, session, token, endpoint) 94 | 95 | {:noreply, state} 96 | end 97 | 98 | def handle_cast({:destroy_guild_voice, guild}, state) do 99 | player = LavaPotion.Struct.Node.player(state.node, guild) 100 | 101 | LavaPotion.Struct.Player.destroy(player) 102 | {:noreply, state} 103 | end 104 | 105 | def handle_cast({:play_track, %{:guild => guild, :track => track}}, state) do 106 | player = LavaPotion.Struct.Node.player(state.node, guild) 107 | 108 | LavaPotion.Struct.Player.play(player, track) 109 | {:noreply, state} 110 | end 111 | 112 | def handle_call({:get_volume, guild}, _from, state) do 113 | {:reply, state.volume, state} 114 | end 115 | 116 | def handle_call({:player_exists?, guild}, _from, state) do 117 | player = LavaPotion.Struct.Node.player(state.node, guild) 118 | 119 | {:reply, player, state} 120 | end 121 | 122 | def via_tuple(bot_id) do 123 | case Horde.Registry.lookup(DiscordGatewayGs.GSRegistry, "lavalink_" <> bot_id) do 124 | [{_, _}] -> {:via, Horde.Registry, {DiscordGatewayGs.GSRegistry, "lavalink_" <> bot_id}} 125 | _ -> 126 | Horde.Supervisor.start_child(DiscordGatewayGs.DistributedSupervisor, %{id: "lavalink_" <> bot_id, start: {DiscordGatewayGs.ModuleExecutor.Modules.Music.LavalinkManager, :start_link, [%{bot_id: bot_id}]}}) 127 | {:via, Horde.Registry, {DiscordGatewayGs.GSRegistry, "lavalink_" <> bot_id}} 128 | end 129 | end 130 | end -------------------------------------------------------------------------------- /lib/connectivity/redis.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.RedisConnector do 2 | use GenServer 3 | 4 | alias DiscordGatewayGs.Structs 5 | 6 | def start_link do 7 | GenServer.start_link(__MODULE__, [], name: :local_redis_client) 8 | end 9 | 10 | def init(_) do 11 | [{_, node_name}] = :ets.lookup(:node_info, "identifier"); 12 | state = %{"node" => node_name} 13 | redis_host = Application.get_env(:discord_gateway_gs, :redis_host) 14 | {:ok, conn} = Redix.PubSub.start_link(host: "localhost", port: 6379) 15 | {:ok, client} = Redix.start_link(host: "localhost", port: 6379) 16 | 17 | Redix.PubSub.subscribe(conn, "cc-core-events", self()) 18 | Redix.PubSub.subscribe(conn, "supreme", self()) 19 | 20 | state = Map.put(state, :client, client) 21 | {:ok, state} 22 | end 23 | 24 | @spec insert_guild(Structs.GuildState, Structs.ChannelState, State.MemberState, State.RoleState) :: :ok | no_return 25 | def insert_guild(guild_state, channels_state, members_state, roles_state) do 26 | guild_state = Map.put(guild_state, :id, Integer.to_string(guild_state.id)) 27 | command = ["HMSET", guild_state.id, "guild", Poison.encode!(guild_state)] 28 | 29 | channels = channels_state 30 | |> Enum.map(fn c -> 31 | ["channel:#{c.id}", Poison.encode!(c)] 32 | end) 33 | |> List.flatten 34 | 35 | command = command ++ channels 36 | 37 | members = members_state 38 | |> Enum.map(fn m -> 39 | ["member:#{m.user.id}", Poison.encode!(m)] 40 | end) 41 | |> List.flatten 42 | 43 | command = command ++ members 44 | 45 | roles = roles_state 46 | |> Enum.map(fn r -> 47 | ["role:#{r.id}", Poison.encode!(r)] 48 | end) 49 | |> List.flatten 50 | 51 | command = command ++ roles 52 | 53 | GenServer.cast :local_redis_client, {:custom, command} 54 | GenServer.cast :local_redis_client, {:expire, guild_state.id, 600} 55 | end 56 | 57 | # 58 | # GenServer Callbacks 59 | # 60 | 61 | def handle_call({:custom, keylist}, _from, state) do 62 | value = Redix.command(state[:client], keylist) 63 | {:reply, value, state} 64 | end 65 | 66 | def handle_call({:get, key}, _from, state) do 67 | value = Redix.command(state[:client], ["GET", key]) 68 | {:reply, value, state} 69 | end 70 | 71 | def handle_call({:hget, hash, key}, _from, state) do 72 | value = Redix.command(state[:client], ["HGET", hash, key]) 73 | {:reply, value, state} 74 | end 75 | 76 | def handle_cast({:custom, keylist}, state) do 77 | Redix.command(state[:client], keylist) 78 | {:noreply, state} 79 | end 80 | 81 | def handle_cast({:set, key, value}, state) do 82 | Redix.command(state[:client], ["SET", key, value]) 83 | {:noreply, state} 84 | end 85 | 86 | def handle_cast({:del, key}, state) do 87 | Redix.command(state[:client], ["DEL", key]) 88 | {:noreply, state} 89 | end 90 | 91 | def handle_cast({:hdel, hash, key}, state) do 92 | Redix.command(state[:client], ["HDEL", hash, key]) 93 | {:noreply, state} 94 | end 95 | 96 | def handle_cast({:append, key, value}, state) do 97 | Redix.command(state[:client], ["APPEND", key, value]) 98 | {:noreply, state} 99 | end 100 | 101 | def handle_cast({:expire, key, seconds}, state) do 102 | Redix.command(state[:client], ["EXPIRE", key, seconds]) 103 | {:noreply, state} 104 | end 105 | 106 | def handle_cast({:publish, channel, message}, state) do 107 | message = Poison.encode!(message) 108 | Redix.command(state[:client], ["PUBLISH", channel, message]) 109 | {:noreply, state} 110 | end 111 | 112 | def handle_info({:redix_pubsub, _pubsub, _pid, :subscribed, %{channel: channel}}, state) do 113 | IO.puts "Redis: subscribed to #{channel}" 114 | {:noreply, state} 115 | end 116 | 117 | def handle_info({:redix_pubsub, _pubsub, _pid, :message, %{channel: channel, payload: payload}}, state) do 118 | IO.puts "Redis: received message #{payload}" 119 | case channel do 120 | "cc-core-events" -> 121 | node_name = state["node"] 122 | data = Poison.decode!(payload) 123 | case data["node"] do 124 | ^node_name -> do_action(data) 125 | nil -> do_action(data) 126 | _ -> {:incorrect_node} 127 | end 128 | "supreme" -> 129 | DiscordGatewayGs.ModuleExecutor.Modules.SupremeMonitor.handle_outer_event({:restock, payload}, "", "") 130 | end 131 | {:noreply, state} 132 | end 133 | 134 | defp do_action(data) do 135 | case data["action"] do 136 | "createNewBotGS" -> Horde.Supervisor.start_child(DiscordGatewayGs.DistributedSupervisor, %{id: "bot_" <> data["data"]["bot_id"], start: {DiscordGatewayGs.TestBotGS, :start_link, [data["data"]]}}) 137 | "updateBotConfig" -> DiscordGatewayGs.TestBotGS.update_bot_config(data["data"]["bot_id"]) 138 | "updatePresence" -> DiscordGatewayGs.TestBotGS.update_presence(data["data"]["bot_id"], %{"since" => nil, "game" => %{"name" => data["data"]["presence_message"], "type" => 0}, "status" => "online", "afk" => false}) 139 | "restartBot" -> 140 | Process.send(:local_node_manager, {:safely_restart_bot, data["bot_id"]}, []) 141 | "stopBotOnNode" -> 142 | Process.send(:local_node_manager, {:safely_terminate_bot, data["bot_id"]}, []) 143 | _ -> IO.puts "unknown_action" 144 | end 145 | end 146 | end -------------------------------------------------------------------------------- /lib/bot_entity.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.TestBotGS do 2 | use GenServer 3 | require Logger 4 | 5 | alias DiscordGatewayGs.GatewayClient 6 | 7 | def start_link(bot_state) do 8 | GenServer.start_link(__MODULE__, bot_state, name: via_tuple(bot_state["bot_id"])) 9 | end 10 | 11 | def init(bot_state) do 12 | Logger.info("Bot GenServer init: #{bot_state["name"]}") 13 | 14 | # Start Gateway client for the bot 15 | {id, _} = Integer.parse(bot_state["bot_id"]) 16 | 17 | internal_api_host = Application.get_env(:discord_gateway_gs, :internal_api) 18 | {_, res} = HTTPoison.get("http://localhost:8888/bots/" <> bot_state["bot_id"]) 19 | config = Poison.decode!(res.body)["data"] 20 | 21 | modules = map_module_config_precedences(config["modules"]) 22 | config = Map.put(config, "modules", modules) 23 | 24 | module_gwe_map = map_modules_to_gwe_subs(config["modules"]) 25 | module_gwe_list = concat_gwe_map(module_gwe_map) 26 | 27 | #Horde.Supervisor.start_child(DiscordGatewayGs.DistributedSupervisor, %{id: data["data"]["bot_id"], start: {DiscordGatewayGs.GatewayClient, :start_link, [data["data"]]}}) 28 | {_, pid} = GatewayClient.start_link(%{:token => bot_state["token"], :presence => (config["interface"]["presence"] || %{"message" => ""}), :bot_id => id, :gwe_map => module_gwe_map, :gwe_list => module_gwe_list}) 29 | 30 | # TEMP: Log to channel in test guild to confirm presence 31 | send self(), {:send_message, "HELLO_ACK from Elixir bot gateway process, GenServer PID: #{inspect(pid)}"} 32 | 33 | bot_state = Map.put(bot_state, :sharder_proc, pid) 34 | |> Map.put(:creator, config["creator"]) 35 | :ets.insert(:available_bots, {bot_state["bot_id"], %{"config" => config, "token" => bot_state["token"]}}) 36 | 37 | Process.flag(:trap_exit, true) 38 | 39 | if config["plan"] == "pro" do 40 | Process.send_after(self(), :billing_task, 10_000) 41 | end 42 | 43 | send self(), {:send_bot_status} 44 | Process.send_after self(), {:send_bot_stats}, 10_000 45 | 46 | {:ok, bot_state} 47 | end 48 | 49 | # Private 50 | 51 | defp schedule_billing_task do 52 | Process.send_after(self(), :billing_task, 3_600_000) 53 | end 54 | 55 | defp schedule_stats_update do 56 | Process.send_after(self(), {:send_bot_stats}, 300_000) 57 | end 58 | 59 | defp map_modules_to_gwe_subs(modules) do 60 | modules 61 | |> Enum.map(fn {k, v} -> 62 | with [{m, i}] <- :ets.lookup(:modules, k) do 63 | %{"internal_reference" => ir, "gwe_sub" => gwes} = i 64 | gwes = gwes |> Enum.map(&String.to_atom/1) 65 | 66 | {ir, gwes} 67 | end 68 | end) 69 | end 70 | 71 | @spec concat_gwe_map(List.t()) :: List.t() 72 | defp concat_gwe_map(gwe_map) do 73 | gwe_map 74 | |> Enum.map(fn t -> 75 | t |> elem(1) 76 | end) 77 | |> Enum.concat 78 | |> Enum.dedup 79 | end 80 | 81 | defp map_module_config_precedences(modules) do 82 | modules 83 | |> Enum.filter(fn {_, v} -> v["enabled"] end) 84 | |> Enum.map(fn {k, v} -> 85 | with [{m, i}] <- :ets.lookup(:modules, k) do 86 | dc = (i["default_config"] || %{}) 87 | new_config = Map.merge((dc || %{}), (v["config"] || %{})) 88 | {k, %{"config" => new_config}} 89 | end 90 | end) 91 | |> Enum.into(%{}) 92 | end 93 | 94 | # GenServer API 95 | 96 | @spec update_bot_config(String.t()) :: :ok 97 | def update_bot_config(bot_id) do 98 | IO.puts bot_id 99 | GenServer.cast via_tuple(bot_id), {:update_config} 100 | end 101 | 102 | def update_presence(bot_id, presence) do 103 | GenServer.cast via_tuple(bot_id), {:update_status, presence} 104 | end 105 | 106 | @spec request_voice_connection(String.t(), String.t(), String.t()) :: :ok 107 | def request_voice_connection(bot_id, guild_id, channel_id) do 108 | GenServer.cast via_tuple(bot_id), {:request_voice, channel_id, guild_id} 109 | end 110 | 111 | @spec destroy_voice_connection(String.t(), String.t()) :: :ok 112 | def destroy_voice_connection(bot_id, guild_id) do 113 | GenServer.cast via_tuple(bot_id), {:leave_voice, guild_id} 114 | end 115 | 116 | # GenServer callbacks 117 | 118 | def handle_info(:billing_task, state) do 119 | data = %{ 120 | "creator" => state.creator, 121 | "amount" => 0.007 122 | } 123 | {_, res} = HTTPoison.post("http://localhost:8888/bots/" <> state["bot_id"] <> "/billing_charge", Poison.encode!(data), [{"Content-Type", "application/json"}]) 124 | %{"exceeded" => exceeded?} = Poison.decode!(res.body) 125 | 126 | if exceeded? do 127 | Process.send(:local_node_manager, {:safely_terminate_bot, state["bot_id"]}, []) 128 | end 129 | 130 | schedule_billing_task() 131 | {:noreply, state} 132 | end 133 | 134 | def handle_info(:safe_term, state) do 135 | {:noreply, state} 136 | end 137 | 138 | def handle_info({:send_message, message}, state) do 139 | HTTPoison.post("https://discordapp.com/api/v6/channels/535097935923380246/messages", Poison.encode!(%{"content" => message}), [{"Authorization", "Bot " <> state["token"]}, {"Content-Type", "application/json"}]) 140 | {:noreply, state} 141 | end 142 | 143 | def handle_cast({:update_config}, state) do 144 | internal_api_host = Application.get_env(:discord_gateway_gs, :internal_api) 145 | {_, res} = HTTPoison.get("http://localhost:8888/bots/" <> state["bot_id"]) 146 | config = Poison.decode!(res.body)["data"] 147 | 148 | modules = map_module_config_precedences(config["modules"]) 149 | config = Map.put(config, "modules", modules) 150 | 151 | module_gwe_map = map_modules_to_gwe_subs(config["modules"]) 152 | module_gwe_list = concat_gwe_map(module_gwe_map) 153 | 154 | send(state[:sharder_proc], {:update_gwe, module_gwe_map, module_gwe_list}) 155 | 156 | :ets.insert(:available_bots, {state["bot_id"], %{"config" => config, "token" => state["token"]}}) 157 | {:noreply, state} 158 | end 159 | 160 | def handle_cast({:update_status, presence}, state) do 161 | send(state[:sharder_proc], {:update_status, presence}) 162 | {:noreply, state} 163 | end 164 | 165 | def handle_cast({:request_voice, channel, guild}, state) do 166 | send(state[:sharder_proc], {:start_voice_connection, %{:channel_id => channel, :guild_id => guild}}) 167 | {:noreply, state} 168 | end 169 | 170 | def handle_cast({:leave_voice, guild}, state) do 171 | send(state[:sharder_proc], {:leave_voice_channel, guild}) 172 | {:noreply, state} 173 | end 174 | 175 | def handle_info({:send_bot_stats}, state) do 176 | Task.start(fn -> 177 | {_, guilds} = GenServer.call(:local_redis_client, {:custom, ["LLEN", state["bot_id"] <> "_guilds"]}) 178 | HTTPoison.post("http://localhost:8888/bots/" <> state["bot_id"] <> "/stats", Poison.encode!(%{"guild_count" => guilds}), [{"Content-Type", "application/json"}]) 179 | end) 180 | schedule_stats_update() 181 | {:noreply, state} 182 | end 183 | 184 | def handle_info({:send_bot_status}, state) do 185 | [{_, node_name}] = :ets.lookup(:node_info, "identifier"); 186 | internal_api_host = Application.get_env(:discord_gateway_gs, :internal_api) 187 | HTTPoison.post("http://localhost:8888/bots/" <> state["bot_id"] <> "/health", Poison.encode!(%{"node" => node_name, "pid" => inspect(self)}), [{"Content-Type", "application/json"}]) 188 | GenServer.cast(:local_redis_client, {:publish, "cc-realtime-events", %{"action" => "bot_hello_ack", "data" => %{"creator": state.creator, "bot_id": state["bot_id"]}}}) 189 | {:noreply, state} 190 | end 191 | 192 | def handle_call(:get_state, _from, state) do 193 | {:reply, state, state} 194 | end 195 | 196 | def terminate(reason, state) do 197 | :ets.delete(:available_bots, state["bot_id"]) 198 | GenServer.cast(:local_redis_client, {:del, "#{state["bot_id"]}_guilds"}) 199 | HTTPoison.post("http://localhost:8888/bots/" <> state["bot_id"] <> "/terminated", []) 200 | GenServer.cast(:local_redis_client, {:publish, "cc-realtime-events", %{"action" => "bot_process_down", "data" => %{"creator": state.creator, "bot_id": state["bot_id"]}}}) 201 | end 202 | 203 | def via_tuple(name), do: {:via, Horde.Registry, {DiscordGatewayGs.GSRegistry, "bot_" <> name}} 204 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/moderation/moderation.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.Moderation do 2 | @behaviour DiscordGatewayGs.ModuleExecutor.CCModule 3 | 4 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 5 | alias DiscordGatewayGs.ModuleExecutor.Snowflake 6 | 7 | # 8 | # Ban 9 | # 10 | 11 | def handle_command({"ban", args}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{:guild_id => guild_id, "channel_id" => channel, "mentions" => [user_to_ban | _]}} = discord_payload) do 12 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":shield: One moment...", channel, token) 13 | 14 | case DiscordActions.ban_user(user_to_ban["id"], guild_id, token) do 15 | {:ok} -> 16 | DiscordActions.edit_message(":shield: User `#{user_to_ban["username"]}\##{user_to_ban["discriminator"]}` has been banned", message_to_edit, channel, token) 17 | {:error, error} -> 18 | case error["code"] do 19 | 50013 -> 20 | DiscordActions.edit_message(":x: I don't have permission to ban that user", message_to_edit, channel, token) 21 | _ -> 22 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 23 | end 24 | end 25 | end 26 | 27 | def handle_command({"ban", [user_id? | _]}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{:guild_id => guild_id, "channel_id" => channel, "mentions" => []}} = discord_payload) do 28 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":shield: One moment...", channel, token) 29 | case DiscordActions.ban_user(user_id?, guild_id, token) do 30 | {:ok} -> 31 | DiscordActions.edit_message(":shield: User has been banned", message_to_edit, channel, token) 32 | {:error, error} -> 33 | case error["code"] do 34 | 50013 -> 35 | DiscordActions.edit_message(":x: I don't have permission to ban that user", message_to_edit, channel, token) 36 | e when is_map(error) -> 37 | DiscordActions.edit_message(":x: Invalid user ID", message_to_edit, channel, token) 38 | _ -> 39 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 40 | end 41 | end 42 | end 43 | 44 | # 45 | # Unban 46 | # 47 | 48 | def handle_command({"unban", args}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{:guild_id => guild_id, "channel_id" => channel, "mentions" => [user_to_unban | _]}} = discord_payload) do 49 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":shield: One moment...", channel, token) 50 | 51 | case DiscordActions.unban_user(user_to_unban["id"], guild_id, token) do 52 | {:ok} -> 53 | DiscordActions.edit_message(":shield: User `#{user_to_unban["username"]}\##{user_to_unban["discriminator"]}` has been unbanned", message_to_edit, channel, token) 54 | {:error, error} -> 55 | IO.inspect error 56 | case error["code"] do 57 | 50013 -> 58 | DiscordActions.edit_message(":x: I don't have permission to unban that user", message_to_edit, channel, token) 59 | 10026 -> 60 | DiscordActions.edit_message(":x: That user isn't banned", message_to_edit, channel, token) 61 | _ -> 62 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 63 | end 64 | end 65 | end 66 | 67 | def handle_command({"unban", [user_id? | _]}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{:guild_id => guild_id, "channel_id" => channel, "mentions" => []}} = discord_payload) do 68 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":shield: One moment...", channel, token) 69 | case DiscordActions.unban_user(user_id?, guild_id, token) do 70 | {:ok} -> 71 | DiscordActions.edit_message(":shield: User has been unbanned", message_to_edit, channel, token) 72 | {:error, error} -> 73 | case error["code"] do 74 | 50013 -> 75 | DiscordActions.edit_message(":x: I don't have permission to unban that user", message_to_edit, channel, token) 76 | 10026 -> 77 | DiscordActions.edit_message(":x: That user isn't banned", message_to_edit, channel, token) 78 | e when is_map(error) -> 79 | DiscordActions.edit_message(":x: Invalid user ID", message_to_edit, channel, token) 80 | _ -> 81 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 82 | end 83 | end 84 | end 85 | 86 | # 87 | # Kick 88 | # 89 | 90 | def handle_command({"kick", args}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{:guild_id => guild_id, "channel_id" => channel, "mentions" => [user_to_kick | _]}} = discord_payload) do 91 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":shield: One moment...", channel, token) 92 | 93 | case DiscordActions.kick_user(user_to_kick["id"], guild_id, token) do 94 | {:ok} -> 95 | DiscordActions.edit_message(":shield: User `#{user_to_kick["username"]}\##{user_to_kick["discriminator"]}` has been kicked", message_to_edit, channel, token) 96 | {:error, error} -> 97 | case error["code"] do 98 | 50013 -> 99 | DiscordActions.edit_message(":x: I don't have permission to kick that user", message_to_edit, channel, token) 100 | _ -> 101 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 102 | end 103 | end 104 | end 105 | 106 | def handle_command({"kick", [user_id? | _]}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{:guild_id => guild_id, "channel_id" => channel, "mentions" => []}} = discord_payload) do 107 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":shield: One moment...", channel, token) 108 | case DiscordActions.kick_user(user_id?, guild_id, token) do 109 | {:ok} -> 110 | DiscordActions.edit_message(":shield: User has been kicked", message_to_edit, channel, token) 111 | {:error, error} -> 112 | case error["code"] do 113 | 50013 -> 114 | DiscordActions.edit_message(":x: I don't have permission to kick that user", message_to_edit, channel, token) 115 | e when is_map(error) -> 116 | DiscordActions.edit_message(":x: Invalid user ID", message_to_edit, channel, token) 117 | _ -> 118 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 119 | end 120 | end 121 | end 122 | 123 | # 124 | # Purge 125 | # 126 | 127 | def handle_command({"purge", [amount? | _]}, %{"authorization" => %{"discord_token" => token}} = bot_config, %{:data => %{:guild_id => guild_id, "channel_id" => channel}} = discord_payload) do 128 | case Integer.parse(amount?) do 129 | {n, _} when is_number(n) and n < 101 and n > 1 -> 130 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":shield: One moment...", channel, token) 131 | 132 | case DiscordActions.fetch_messages(channel, token, n, message_to_edit) do 133 | {:ok, messages} -> 134 | message_ids = messages |> Enum.map(fn m -> m["id"] end) 135 | case DiscordActions.purge_messages(message_ids, channel, token) do 136 | {:ok} -> 137 | DiscordActions.edit_message(":shield: Purged `#{n}` messages", message_to_edit, channel, token) 138 | {:error, error} -> 139 | case error["code"] do 140 | 50013 -> 141 | DiscordActions.edit_message(":x: I don't have permission to purge messages", message_to_edit, channel, token) 142 | _ -> 143 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 144 | end 145 | end 146 | {:error} -> 147 | DiscordActions.edit_message(":x: Sorry, an unknown error occurred", message_to_edit, channel, token) 148 | end 149 | {n, _} when is_number(n) -> 150 | DiscordActions.send_message_to_channel(":x: Purge amount must be between 2 and 100", channel, token) 151 | _ -> 152 | DiscordActions.send_message_to_channel(":x: You must specify an amount of messages to purge", channel, token) 153 | end 154 | end 155 | end -------------------------------------------------------------------------------- /lib/module_executor/modules/music/music.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.ModuleExecutor.Modules.Music do 2 | @behaviour DiscordGatewayGs.ModuleExecutor.CCModule 3 | 4 | alias LavaPotion.Struct.LoadTrackResponse 5 | alias DiscordGatewayGs.ModuleExecutor.Actions.DiscordActions 6 | alias DiscordGatewayGs.ModuleExecutor.Modules.Music.LavalinkManager 7 | 8 | def handle_command({"join", _}, %{"authorization" => %{"discord_token" => token}, "id" => id} = bot_config, %{:data => %{"channel_id" => channel, :guild_id => guild, "author" => author}} = discord_payload) do 9 | {_, member} = GenServer.call :local_redis_client, {:hget, Integer.to_string(guild), "member:" <> Integer.to_string(author["id"])} 10 | 11 | %{"voice" => vc} = member |> Poison.decode! 12 | 13 | case vc do 14 | nil -> 15 | DiscordActions.send_message_to_channel(":x: You need to connect to a voice channel to use this command.", channel, token) 16 | _ -> 17 | DiscordGatewayGs.TestBotGS.request_voice_connection(id, guild, vc["channel_id"]) 18 | DiscordActions.send_message_to_channel(":white_check_mark: Joined.", channel, token) 19 | end 20 | end 21 | 22 | def handle_command({"leave", _}, %{"authorization" => %{"discord_token" => token}, "id" => id} = bot_config, %{:data => %{"channel_id" => channel, :guild_id => guild}} = discord_payload) do 23 | DiscordGatewayGs.TestBotGS.destroy_voice_connection(id, guild) 24 | LavalinkManager.destroy_guild_voice(id, Integer.to_string(guild)) 25 | 26 | DiscordActions.send_message_to_channel("Goodbye! :wave:", channel, token) 27 | end 28 | 29 | def handle_command({"volume", args}, %{"authorization" => %{"discord_token" => token}, "id" => id} = bot_config, %{:data => %{"channel_id" => channel, :guild_id => guild}} = discord_payload) do 30 | with {:ok, _} <- LavalinkManager.guild_player_presence?(id, Integer.to_string(guild), channel, token) do 31 | # Below is temp, don't worry lol 32 | command_opts = bot_config["modules"]["music"]["config"]["command_options"]["volume"] 33 | case args do 34 | [volume | _] -> 35 | volume = Integer.parse(volume) 36 | case volume do 37 | {v, _} when is_integer(v) and v <= 1000 and v >= 0 -> 38 | LavalinkManager.set_volume(id, Integer.to_string(guild), v) 39 | DiscordActions.send_message_to_channel(":loud_sound: Volume set to `#{v}`", channel, token) 40 | _ -> 41 | DiscordActions.send_message_to_channel(":x: Sorry! Volume must be between 0 and 1000 (default is 100)", channel, token) 42 | end 43 | _ -> 44 | volume = LavalinkManager.get_volume(id, Integer.to_string(guild)) 45 | DiscordActions.send_message_to_channel(String.replace(command_opts["messages"]["CURRENT_VOLUME"]["message"], "{volume}", Integer.to_string(volume)), channel, token) 46 | end 47 | end 48 | end 49 | 50 | def handle_command({"clearqueue", _}, %{"authorization" => %{"discord_token" => token}, "id" => id} = bot_config, %{:data => %{"channel_id" => channel, :guild_id => guild}} = discord_payload) do 51 | with {:ok, _} <- LavalinkManager.guild_player_presence?(id, Integer.to_string(guild), channel, token) do 52 | clear_queue(id, Integer.to_string(guild)) 53 | DiscordActions.send_message_to_channel(":notepad_spiral: The queue has been cleared.", channel, token) 54 | end 55 | end 56 | 57 | def handle_command({"skip", _}, %{"authorization" => %{"discord_token" => token}, "id" => id} = bot_config, %{:data => %{"channel_id" => channel, :guild_id => guild}} = discord_payload) do 58 | with {:ok, _} <- LavalinkManager.guild_player_presence?(id, Integer.to_string(guild), channel, token) do 59 | #play_next_in_queue(id, Integer.to_string(guild)) 60 | end 61 | end 62 | 63 | def handle_command({"play", args}, %{"authorization" => %{"discord_token" => token}, "id" => id} = bot_config, %{:data => %{"channel_id" => channel, :guild_id => guild, "author" => author}} = discord_payload) do 64 | with {:ok, _} <- LavalinkManager.guild_player_presence?(id, Integer.to_string(guild), channel, token) do 65 | {_, %{"id" => message_to_edit}} = DiscordActions.send_message_to_channel(":mag_right: Searching...", channel, token) 66 | 67 | is_yt_id? = Regex.match?(~r/[a-zA-Z0-9_-]{11}/, Enum.at(args, 0)) 68 | 69 | results = case is_yt_id? do 70 | true -> LavaPotion.Api.load_tracks(Enum.at(args, 0)) 71 | false -> LavaPotion.Api.load_tracks("ytsearch:#{Enum.join(args, " ")}") 72 | end 73 | 74 | case results do 75 | %LoadTrackResponse{loadType: "SEARCH_RESULT", tracks: [%{"info" => %{"author" => author, "identifier" => yt_id, "title" => title, "length" => length, "uri" => uri}, "track" => encoded_track} = track | _]} -> 76 | DiscordActions.edit_message(":notepad_spiral: Added to queue: `#{title}`", message_to_edit, channel, token) 77 | #LavalinkManager.play_track(id, Integer.to_string(guild), encoded_track) 78 | add_to_queue(id, Integer.to_string(guild), %{"encoded_track" => encoded_track, "uri" => uri, "id" => yt_id, "length" => length, "title" => title, "channel" => channel}) 79 | play_now_if_no_player(id, Integer.to_string(guild)) 80 | #play_next_in_queue(id, Integer.to_string(guild)) 81 | %LoadTrackResponse{loadType: "TRACK_LOADED", tracks: [%{"info" => %{"author" => author, "identifier" => yt_id, "title" => title, "length" => length, "uri" => uri}, "track" => encoded_track} = track | _]} -> 82 | DiscordActions.edit_message(":notepad_spiral: Added to queue: `#{title}`", message_to_edit, channel, token) 83 | #LavalinkManager.play_track(id, Integer.to_string(guild), encoded_track) 84 | add_to_queue(id, Integer.to_string(guild), %{"encoded_track" => encoded_track, "uri" => uri, "id" => yt_id, "length" => length, "title" => title, "channel" => channel}) 85 | play_now_if_no_player(id, Integer.to_string(guild)) 86 | _ -> IO.puts DiscordActions.edit_message("Sorry, an error occurred with Lavalink", message_to_edit, channel, token) 87 | end 88 | end 89 | end 90 | 91 | def handle_command({"queue", _}, %{"authorization" => %{"discord_token" => token}, "id" => id} = bot_config, %{:data => %{"channel_id" => channel, :guild_id => guild}} = discord_payload) do 92 | with {:ok, _} <- LavalinkManager.guild_player_presence?(id, Integer.to_string(guild), channel, token) do 93 | case get_full_queue(id, Integer.to_string(guild)) do 94 | {:ok, tracks} -> 95 | queue_string = tracks 96 | |> Enum.map(fn song -> 97 | "#{song["title"]}\n" 98 | end) 99 | DiscordActions.send_message_to_channel("Current queue:\n#{queue_string}", channel, token) 100 | {:error, err} -> 101 | DiscordActions.send_message_to_channel(":x: #{err}", channel, token) 102 | end 103 | end 104 | end 105 | 106 | def handle_event(:voice_server_update, %{:data => data} = payload, bot_id) do 107 | [{_, session}] = :ets.lookup(:bot_sessions, bot_id) 108 | 109 | with :dispatch <- payload.op do 110 | LavalinkManager.initialize_guild(bot_id, Integer.to_string(data.guild_id), session, data.token, data.endpoint) 111 | end 112 | end 113 | 114 | # 115 | # Private 116 | # 117 | 118 | def get_full_queue(bot_id, guild_id) when is_binary(guild_id) do 119 | {_, queue_buffer} = GenServer.call :local_redis_client, {:get, "#{bot_id}_#{guild_id}_musicqueue"} 120 | 121 | case queue_buffer do 122 | nil -> {:error, "No songs in the queue!"} 123 | _ -> 124 | track_maps = queue_buffer 125 | |> String.split("|", trim: true) 126 | |> Enum.map(fn b -> 127 | b 128 | |> Base.decode64!(padding: false) 129 | |> Poison.decode! 130 | end) 131 | {:ok, track_maps} 132 | end 133 | end 134 | 135 | def get_next_in_queue(bot_id, guild_id) when is_binary(guild_id) do 136 | {_, queue_buffer} = GenServer.call :local_redis_client, {:get, "#{bot_id}_#{guild_id}_musicqueue"} 137 | 138 | case queue_buffer do 139 | nil -> 140 | {:error, "No songs in queue"} 141 | _ -> 142 | track = queue_buffer 143 | |> String.split("|", trim: true) 144 | |> Enum.at(0) 145 | |> Base.decode64!(padding: false) 146 | |> IO.inspect 147 | |> Poison.decode! 148 | 149 | {:ok, track} 150 | end 151 | end 152 | 153 | def add_to_queue(bot_id, guild_id, track) when is_binary(guild_id) do 154 | track = track 155 | |> Poison.encode! 156 | |> Base.encode64(padding: false) 157 | 158 | GenServer.cast :local_redis_client, {:append, "#{bot_id}_#{guild_id}_musicqueue", track <> "|"} 159 | 160 | {:ok} 161 | end 162 | 163 | def nudge_queue(bot_id, guild_id) when is_binary(guild_id) do 164 | {_, queue_buffer} = GenServer.call :local_redis_client, {:get, "#{bot_id}_#{guild_id}_musicqueue"} 165 | 166 | track = queue_buffer 167 | |> String.split("|", trim: true) 168 | |> List.delete_at(0) 169 | |> Enum.join("|") 170 | |> IO.inspect 171 | 172 | #String.length(track) && GenServer.cast :local_redis_client, {:del, "#{bot_id}_#{guild_id}_musicqueue"} || GenServer.cast :local_redis_client, {:set, "#{bot_id}_#{guild_id}_musicqueue", track} 173 | case String.length(track) do 174 | 0 -> 175 | GenServer.cast :local_redis_client, {:del, "#{bot_id}_#{guild_id}_musicqueue"} 176 | _ -> 177 | GenServer.cast :local_redis_client, {:set, "#{bot_id}_#{guild_id}_musicqueue", track <> "|"} 178 | end 179 | 180 | {:ok} 181 | end 182 | 183 | def clear_queue(bot_id, guild_id) when is_binary(guild_id) do 184 | GenServer.cast :local_redis_client, {:del, "#{bot_id}_#{guild_id}_musicqueue"} 185 | end 186 | 187 | def play_next_in_queue(bot_id, guild_id) when is_binary(guild_id) do 188 | [{id, %{"config" => %{"authorization" => %{"discord_token" => token}}}}] = :ets.lookup(:available_bots, bot_id) 189 | 190 | ns = get_next_in_queue(bot_id, guild_id) 191 | 192 | case ns do 193 | {:ok, song} -> 194 | LavalinkManager.play_track(bot_id, guild_id, song["encoded_track"]) 195 | 196 | DiscordActions.send_message_to_channel(":musical_note: Now playing: `#{song["title"]}`", song["channel"], token) 197 | nudge_queue(bot_id, guild_id) 198 | {:error, err} -> 199 | #DiscordActions.send_message_to_channel(":notepad_spiral: The queue is empty. Add more songs using `!play `\n*If no songs are added within 5 minutes I'll leave the channel*", song["channel"], token) 200 | end 201 | end 202 | 203 | def play_now_if_no_player(bot_id, guild_id) when is_binary(guild_id) do 204 | player? = LavalinkManager.guild_is_playing?(bot_id, guild_id) 205 | 206 | if(!player?) do 207 | play_next_in_queue(bot_id, guild_id) 208 | end 209 | end 210 | 211 | end -------------------------------------------------------------------------------- /lib/connectivity/lavalink/struct/node.ex: -------------------------------------------------------------------------------- 1 | defmodule LavaPotion.Struct.Node do 2 | use WebSockex 3 | 4 | import Poison 5 | 6 | alias LavaPotion.Struct.{VoiceUpdate, Play, Pause, Stop, Destroy, Volume, Seek, Player, Stats} 7 | alias LavaPotion.Stage.Producer 8 | 9 | require Logger 10 | 11 | defstruct [:password, :port, :address, :client] 12 | 13 | @ets_lookup :lavapotion_ets_table 14 | @stats_max_int :math.pow(2, 31) - 1 15 | @stats_no_stats @stats_max_int - 1 16 | 17 | @typedoc """ 18 | 19 | """ 20 | @type t :: %__MODULE__{} 21 | 22 | def new(opts) do 23 | client = opts[:client] 24 | if client == nil do 25 | raise "client is nil" 26 | end 27 | 28 | address = opts[:address] 29 | if !is_binary(address) do 30 | raise "address is not a binary string" 31 | end 32 | 33 | port = opts[:port] || client.default_port 34 | if !is_number(port) do 35 | raise "port is not a number" 36 | end 37 | 38 | password = opts[:password] || client.default_password 39 | if !is_binary(password) do 40 | raise "password is not a binary string" 41 | end 42 | 43 | %__MODULE__{client: client, password: password, address: address, port: port} 44 | end 45 | 46 | def start_link(mod) do 47 | result = {:ok, pid} = WebSockex.start_link("ws://#{mod.address}:#{mod.port}", __MODULE__, %{}, 48 | extra_headers: ["User-Id": mod.client.user_id, "Authorization": mod.password, "Num-Shards": mod.client.shard_count], 49 | handle_initial_conn_failure: true, async: true) 50 | if :ets.whereis(@ets_lookup) === :undefined, do: :ets.new(@ets_lookup, [:set, :public, :named_table]) 51 | :ets.insert(@ets_lookup, {"#{mod.client.user_id}_#{mod.address}", %{node: mod, stats: nil, players: %{}, pid: pid}}) 52 | result 53 | end 54 | 55 | def handle_connect(conn, _state) do 56 | Logger.info "Connected to #{conn.host}!" 57 | {:ok, conn} 58 | end 59 | 60 | def handle_disconnect(%{reason: {:local, _}, conn: conn}, state) do 61 | Logger.info "Client disconnected from #{conn.host}!" 62 | {:ok, state} 63 | end 64 | 65 | def handle_disconnect(%{reason: {:local, _, _}, conn: conn}, state) do 66 | Logger.info "Client disconnected from #{conn.host}!" 67 | {:ok, state} 68 | end 69 | 70 | def handle_disconnect(%{reason: {:remote, code, message}, attempt_number: attempt_number, conn: conn}, state) when attempt_number < 5 do 71 | # todo change to info if code = 1001 or 1000 72 | Logger.warn "Disconnected from #{conn.host} by server with code: #{code} and message: #{message}! Reconnecting..." 73 | {:reconnect, state} 74 | end 75 | 76 | def handle_disconnect(%{reason: {:remote, code, message}, conn: conn}, state) do 77 | # todo change to info if code == 1001 or 1000 78 | Logger.warn "Disconnected from #{conn.host} by server with code: #{code} and message: #{message}!" 79 | {:ok, state} 80 | end 81 | 82 | def handle_disconnect(%{reason: {:remote, :closed}, conn: conn}, state) do 83 | Logger.warn "Abruptly disconnected from #{conn.host} by server!" 84 | {:ok, state} 85 | end 86 | 87 | defp best_node_iter(current = {_node, record}, nodes) do 88 | if Enum.empty?(nodes) do 89 | current 90 | else 91 | node = List.first(nodes) 92 | nodes = List.delete_at(nodes, 0) 93 | result = case node do 94 | {_host, %{node: node = %__MODULE__{}, stats: nil}} -> {node, @stats_no_stats} 95 | {_host, %{node: node = %__MODULE__{}, stats: %Stats{playing_players: playing_players, cpu: %{"systemLoad" => system_load}, frame_stats: %{"nulled" => nulled, "deficit" => deficit}}}} -> 96 | {node, playing_players} 97 | {_host, %{node: node = %__MODULE__{}, stats: %Stats{playing_players: playing_players, cpu: %{"systemLoad" => system_load}, frame_stats: nil}}} -> 98 | {node, playing_players} 99 | {_host, %{node: node = %__MODULE__{}}} -> {node, @stats_no_stats} 100 | _ -> {:error, :malformed_data} 101 | end 102 | 103 | if result !== {:error, :malformed_data} && elem(result, 1) < record do 104 | best_node_iter(result, nodes) 105 | else 106 | best_node_iter(current, nodes) 107 | end 108 | end 109 | end 110 | 111 | def best_node() do 112 | list = :ets.tab2list(@ets_lookup) # woefully inefficient, might replace with select later? 113 | case best_node_iter({nil, @stats_max_int}, list) do 114 | {nil, _} -> {:error, :no_available_node} 115 | {node = %__MODULE__{}, _} -> {:ok, node} 116 | _ -> {:error, :malformed_return_value} 117 | end 118 | end 119 | 120 | def pid(%__MODULE__{address: address, client: %LavaPotion.Struct.Client{user_id: id}}), do: pid(address) 121 | 122 | def pid(address, bot_id) when is_binary(address) do 123 | [{_, %{pid: pid}}] = :ets.lookup(@ets_lookup, "#{bot_id}_#{address}") 124 | pid 125 | end 126 | 127 | def pid(node_full_identifier) when is_binary(node_full_identifier) do 128 | [{_, %{pid: pid}}] = :ets.lookup(@ets_lookup, node_full_identifier) 129 | pid 130 | end 131 | 132 | def node(address, bot_id) when is_binary(address) do 133 | [{_, %{node: node = %__MODULE__{}}}] = :ets.lookup(@ets_lookup, "#{bot_id}_#{address}") 134 | node 135 | end 136 | 137 | def players(%__MODULE__{address: address}), do: players(address) 138 | def players(address, bot_id) when is_binary(address) do 139 | [{_, %{players: players}}] = :ets.lookup(@ets_lookup, "#{bot_id}_#{address}") 140 | players 141 | end 142 | 143 | def player(%__MODULE__{address: address, client: %LavaPotion.Struct.Client{user_id: id}}, guild_id), do: player(address, guild_id, id) 144 | def player(address, guild_id, bot_id) when is_binary(address) and is_binary(guild_id) do 145 | IO.puts bot_id 146 | [{_, %{players: players}}] = :ets.lookup(@ets_lookup, "#{bot_id}_#{address}") 147 | players[guild_id] 148 | end 149 | 150 | def handle_cast({:voice_update, player = %Player{guild_id: guild_id, token: token, endpoint: endpoint, session_id: session_id, is_real: false}}, state) do 151 | event = %{guild_id: guild_id, token: token, endpoint: endpoint} 152 | update = encode!(%VoiceUpdate{guildId: guild_id, sessionId: session_id, event: event}) 153 | [{_, map = %{node: node, players: players}}] = :ets.lookup(@ets_lookup, "#{state.extra_headers[:"User-Id"]}_#{state.host}") 154 | players = Map.put(players, guild_id, %Player{player | node: node, is_real: true}) 155 | 156 | :ets.insert(@ets_lookup, {"#{state.extra_headers[:"User-Id"]}_#{state.host}", %{map | players: players}}) 157 | {:reply, {:text, update}, state} 158 | end 159 | 160 | def handle_cast({:play, player = %Player{guild_id: guild_id, is_real: true}, data = {track, _info}}, state) do 161 | update = encode!(%Play{guildId: guild_id, track: track}) 162 | [{_, map = %{players: players}}] = :ets.lookup(@ets_lookup, "#{state.extra_headers[:"User-Id"]}_#{state.host}") 163 | players = Map.put(players, guild_id, %Player{player | track: data}) 164 | 165 | :ets.insert(@ets_lookup, {"#{state.extra_headers[:"User-Id"]}_#{state.host}", %{map | players: players}}) 166 | {:reply, {:text, update}, state} 167 | end 168 | 169 | def handle_cast({:volume, player = %Player{guild_id: guild_id, is_real: true}, volume}, state) do 170 | update = encode!(%Volume{guildId: guild_id, volume: volume}) 171 | [{_, map = %{players: players}}] = :ets.lookup(@ets_lookup, "#{state.extra_headers[:"User-Id"]}_#{state.host}") 172 | players = Map.put(players, guild_id, %Player{player | volume: volume}) 173 | 174 | :ets.insert(@ets_lookup, {"#{state.extra_headers[:"User-Id"]}_#{state.host}", %{map | players: players}}) 175 | {:reply, {:text, update}, state} 176 | end 177 | 178 | def handle_cast({:seek, %Player{guild_id: guild_id, is_real: true, track: {_data, %{"length" => length}}}, position}, state) do 179 | if position > length do 180 | Logger.warn("guild id: #{guild_id} | specified position (#{inspect position}) is larger than the length of the track (#{inspect length})") 181 | {:ok, state} 182 | else 183 | update = encode!(%Seek{guildId: guild_id, position: position}) 184 | # updated upon player update 185 | {:reply, {:text, update}, state} 186 | end 187 | end 188 | 189 | def handle_cast({:pause, player = %Player{guild_id: guild_id, is_real: true, paused: paused}, pause}, state) do 190 | if pause == paused do 191 | {:ok, state} 192 | else 193 | update = encode!(%Pause{guildId: guild_id, pause: pause}) 194 | [{_, map = %{players: players}}] = :ets.lookup(@ets_lookup, "#{state.extra_headers[:"User-Id"]}_#{state.host}") 195 | players = Map.put(players, guild_id, %Player{player | paused: pause}) 196 | 197 | :ets.insert(@ets_lookup, {"#{state.extra_headers[:"User-Id"]}_#{state.host}", %{map | players: players}}) 198 | {:reply, {:text, update}, state} 199 | end 200 | end 201 | 202 | def handle_cast({:destroy, %Player{guild_id: guild_id, is_real: true}}, state) do 203 | update = encode!(%Destroy{guildId: guild_id}) 204 | [{_, map = %{players: players}}] = :ets.lookup(@ets_lookup, "#{state.extra_headers[:"User-Id"]}_#{state.host}") 205 | players = Map.delete(players, guild_id) 206 | 207 | :ets.insert(@ets_lookup, {"#{state.extra_headers[:"User-Id"]}_#{state.host}", %{map | players: players}}) 208 | {:reply, {:text, update}, state} 209 | end 210 | 211 | def handle_cast({:stop, %Player{guild_id: guild_id, is_real: true, track: track}}, state) do 212 | if track == nil do 213 | Logger.warn "player for guild id #{guild_id} already isn't playing anything." 214 | {:ok, state} 215 | else 216 | update = encode!(%Stop{guildId: guild_id}) 217 | # updated upon TrackEndEvent 218 | {:reply, {:text, update}, state} 219 | end 220 | end 221 | 222 | def handle_cast({:update_node, player = %Player{guild_id: guild_id, is_real: true, node: old_node = %__MODULE__{}}, new_node = %__MODULE__{}}, state) when new_node !== old_node do 223 | Player.destroy(player) 224 | [{_, map = %{players: players}}] = :ets.lookup(@ets_lookup, "#{state.client.user_id}_#{state.address}") 225 | player = %Player{player | node: new_node, is_real: false} 226 | players = Map.put(players, guild_id, player) 227 | 228 | Player.initialize(player) 229 | :ets.insert(@ets_lookup, {"#{state.extra_headers[:"User-Id"]}_#{state.host}", %{map | players: players}}) 230 | {:ok, state} 231 | end 232 | 233 | def handle_cast({:update_node, %Player{guild_id: guild_id, is_real: true, node: old_node = %__MODULE__{}}, new_node = %__MODULE__{}}, state) when new_node === old_node do 234 | Logger.warn "player for guild id #{guild_id} attempt to update node to current node?" 235 | {:ok, state} 236 | end 237 | 238 | def terminate(_reason, state) do 239 | Logger.warn "Connection to #{state.host} terminated!" 240 | exit(:normal) 241 | end 242 | 243 | def handle_frame({:text, message}, state) do 244 | data = %{"op" => _op} = Poison.decode!(message) 245 | |> Map.merge(%{"host" => state.host, "bot_id" => state.extra_headers[:"User-Id"]}) 246 | Producer.notify(data) 247 | {:ok, state} 248 | end 249 | def handle_frame(_frame, state), do: {:ok, state} 250 | end -------------------------------------------------------------------------------- /lib/discord_gateway_client/client.ex: -------------------------------------------------------------------------------- 1 | defmodule DiscordGatewayGs.GatewayClient do 2 | # a lot of functionality here yoinked from: https://github.com/rmcafee/discord_ex/blob/master/lib/discord_ex/client/client.ex 3 | require Logger 4 | 5 | alias DiscordGatewayGs.GatewayClient.Heartbeat 6 | 7 | import DiscordGatewayGs.GatewayClient.Utility 8 | 9 | @behaviour :websocket_client 10 | 11 | def opcodes do 12 | %{ 13 | :dispatch => 0, 14 | :heartbeat => 1, 15 | :identify => 2, 16 | :status_update => 3, 17 | :voice_state_update => 4, 18 | :voice_server_ping => 5, 19 | :resume => 6, 20 | :reconnect => 7, 21 | :request_guild_members => 8, 22 | :invalid_session => 9, 23 | :hello => 10, 24 | :heartbeat_ack => 11 25 | } 26 | end 27 | 28 | def start_link(opts) do 29 | opts = Map.put(opts, :client_id, opts[:bot_id]) 30 | |> Map.put(:guilds, []) 31 | |> Map.put(:token, opts[:token]) 32 | |> Map.put(:gwe_map, opts[:gwe_map]) 33 | |> Map.put(:gwe_list, opts[:gwe_list]) 34 | 35 | :crypto.start() 36 | :ssl.start() 37 | :websocket_client.start_link("wss://gateway.discord.gg/?encoding=etf", __MODULE__, opts) 38 | end 39 | 40 | def init(state) do 41 | # State sequence management process and set it's state 42 | {:ok, agent_seq_num} = Agent.start_link fn -> nil end 43 | 44 | new_state = state 45 | |> Map.put(:client_pid, self()) # Pass the client state to use it 46 | |> Map.put(:agent_seq_num, agent_seq_num) # Pass agent sequence num 47 | |> Map.put(:heartbeat_pid, nil) # Place for Heartbeat process pid 48 | 49 | IO.puts inspect(self()) 50 | {:once, new_state} 51 | end 52 | 53 | def onconnect(_WSReq, state) do 54 | # Send identifier to discord gateway 55 | identify(state) 56 | {:ok, state} 57 | end 58 | 59 | def ondisconnect({:remote, :closed}, state) do 60 | # Reconnection with resume opcode should be attempted here 61 | {:close, {:remote, :closed}, state} 62 | end 63 | 64 | def voice_state_update(client_pid, guild_id, channel_id, user_id, options \\ %{}) do 65 | data = options |> Map.merge(%{guild_id: guild_id, channel_id: channel_id, user_id: user_id}) 66 | send(client_pid, {:voice_state_update, data}) 67 | :ok 68 | end 69 | 70 | def websocket_handle({:binary, payload}, _socket, state) do 71 | data = payload_decode(opcodes(), {:binary, payload}) 72 | # Keeps the sequence tracker process updated 73 | _update_agent_sequence(data, state) 74 | 75 | # Handle data based on opcode sent by Discord 76 | _handle_data(data, state) 77 | end 78 | 79 | defp _handle_data(%{op: :hello} = data, state) do 80 | # Discord sends hello op immediately after connection 81 | # Start sending heartbeat with interval defined by the hello packet 82 | Logger.debug("Discord: Hello") 83 | {:ok, heartbeat_pid} = Heartbeat.start_link( 84 | state[:agent_seq_num], 85 | data.data.heartbeat_interval, 86 | self() 87 | ) 88 | 89 | {:ok, %{state | heartbeat_pid: heartbeat_pid}} 90 | end 91 | 92 | defp _handle_data(%{op: :heartbeat_ack} = _data, state) do 93 | # Discord sends heartbeat_ack after we send a heartbeat 94 | # If ack is not received, the connection is stale 95 | Logger.debug("Discord: Heartbeat ACK") 96 | Heartbeat.ack(state[:heartbeat_pid]) 97 | {:ok, state} 98 | end 99 | 100 | defp _handle_data(%{op: :dispatch, event_name: event_name} = data, state) do 101 | # Dispatch op carries actual content like channel messages 102 | DiscordGatewayGs.Statix.increment_gwe 103 | if event_name == :READY do 104 | # Client is ready 105 | #Logger.debug(fn -> "Discord: Dispatch #{event_name}" end) 106 | end 107 | event = normalize_atom(event_name) 108 | 109 | # Call event handler unless it is a static event 110 | if state[:handler] && !_static_event?(event) do 111 | state[:handler].handle_event({event, data}, state) 112 | else 113 | handle_event({event, data}, state) 114 | end 115 | end 116 | 117 | defp _handle_data(%{op: :reconnect} = _data, state) do 118 | Logger.warn("Discord enforced Reconnect") 119 | # Discord enforces reconnection. Websocket should be 120 | # reconnected and resume opcode sent to playback missed messages. 121 | # For now just kill the connection so that a supervisor can restart us. 122 | {:close, "Discord enforced reconnect", state} 123 | end 124 | 125 | defp _handle_data(%{op: :invalid_session} = _data, state) do 126 | Logger.warn("Discord: Invalid session") 127 | # On resume Discord will send invalid_session if our session id is too old 128 | # to be resumed. 129 | # For now just kill the connection so that a supervisor can restart us. 130 | {:close, "Invalid session", state} 131 | end 132 | 133 | def websocket_info(:start, _connection, state) do 134 | {:ok, state} 135 | end 136 | 137 | @doc "Look into state - grab key value and pass it back to calling process" 138 | def websocket_info({:get_state, key, pid}, _connection, state) do 139 | send(pid, {key, state[key]}) 140 | {:ok, state} 141 | end 142 | 143 | @doc "Ability to update websocket client state" 144 | def websocket_info({:update_state, update_values}, _connection, state) do 145 | {:ok, Map.merge(state, update_values)} 146 | end 147 | 148 | @doc "Remove key from state" 149 | def websocket_info({:clear_from_state, keys}, _connection, state) do 150 | new_state = Map.drop(state, keys) 151 | {:ok, new_state} 152 | end 153 | 154 | def websocket_info({:update_status, new_status}, _connection, state) do 155 | payload = payload_build(opcode(opcodes(), :status_update), new_status) 156 | :websocket_client.cast(self(), {:binary, payload}) 157 | {:ok, state} 158 | end 159 | 160 | def websocket_info({:update_gwe, gwe_map, gwe_list}, _connection, state) do 161 | new_state = state 162 | |> Map.put(:gwe_map, gwe_map) 163 | |> Map.put(:gwe_list, gwe_list) 164 | 165 | {:ok, new_state} 166 | end 167 | 168 | #def websocket_info({:update_gwe, gwe_map, gwe_list}, _connection, state) do 169 | # data = %{"idle_since" => idle_since, "game" => %{"name" => game_name}} 170 | # send(state[:client_pid], {:update_status, data}) 171 | # 172 | # {:ok, new_state} 173 | #end 174 | 175 | def websocket_info({:start_voice_connection, options}, _connection, state) do 176 | self_mute = if options[:self_mute] == nil, do: false, else: options[:self_mute] 177 | self_deaf = if options[:self_deaf] == nil, do: false, else: options[:self_mute] 178 | data = %{ 179 | "channel_id" => options[:channel_id], 180 | "guild_id" => options[:guild_id], 181 | "self_mute" => self_mute, 182 | "self_deaf" => self_deaf 183 | } 184 | payload = payload_build(opcode(opcodes(), :voice_state_update), data) 185 | :websocket_client.cast(self(), {:binary, payload}) 186 | {:ok, state} 187 | end 188 | 189 | def websocket_info({:leave_voice_channel, guild_id}, _connection, state) do 190 | data = %{ 191 | "channel_id" => nil, 192 | "guild_id" => guild_id 193 | } 194 | payload = payload_build(opcode(opcodes(), :voice_state_update), data) 195 | :websocket_client.cast(self(), {:binary, payload}) 196 | {:ok, state} 197 | end 198 | 199 | def websocket_info({:start_voice_connection_listener, caller}, _connection, state) do 200 | setup_pid = spawn(fn -> _voice_setup_gather_data(caller, %{}, state) end) 201 | updated_state = Map.merge(state, %{voice_setup: setup_pid}) 202 | {:ok, updated_state} 203 | end 204 | 205 | def websocket_info({:start_voice_connection_listener, caller}, _connection, state) do 206 | setup_pid = spawn(fn -> _voice_setup_gather_data(caller, %{}, state) end) 207 | updated_state = Map.merge(state, %{voice_setup: setup_pid}) 208 | {:ok, updated_state} 209 | end 210 | 211 | def websocket_info({:voice_state_update, opts}, _connection, state) do 212 | data = for {key, val} <- opts, into: %{}, do: {Atom.to_string(key), val} 213 | payload = payload_build(opcode(opcodes(), :voice_state_update), data) 214 | :websocket_client.cast(self(), {:binary, payload}) 215 | {:ok, state} 216 | end 217 | 218 | def websocket_info(:heartbeat_stale, _connection, state) do 219 | # Heartbeat process reports stale connection. Websocket should be 220 | # reconnected and resume opcode sent to playback missed messages. 221 | # For now just kill the connection so that a supervisor can restart us. 222 | {:close, "Heartbeat stale", state} 223 | end 224 | 225 | def websocket_info(:expire_guilds_key, _connection, state) do 226 | GenServer.cast(:local_redis_client, {:expire, "#{state[:bot_id]}_guilds", 600}) 227 | schedule_expire_guilds_key() 228 | {:ok, state} 229 | end 230 | 231 | @spec websocket_terminate(any(), any(), nil | keyword() | map()) :: :ok 232 | def websocket_terminate(reason, _conn_state, state) do 233 | Logger.info "Websocket closed in state #{inspect state} with reason #{inspect reason}" 234 | Logger.info "Killing seq_num process!" 235 | Process.exit(state[:agent_seq_num], :kill) 236 | Logger.info "Killing rest_client process!" 237 | Process.exit(state[:rest_client], :kill) 238 | Logger.info "Killing heartbeat process!" 239 | Process.exit(state[:heartbeat_pid], :kill) 240 | :ok 241 | end 242 | 243 | defp schedule_expire_guilds_key do 244 | Process.send_after(self(), :expire_guilds_key, 500_000) 245 | end 246 | 247 | def handle_event({:ready, payload}, state) do 248 | new_state = Map.put(state, :session_id, payload.data[:session_id]) 249 | 250 | :ets.insert(:bot_sessions, {Integer.to_string(state[:bot_id]), payload.data[:session_id]}) 251 | 252 | send self(), :expire_guilds_key 253 | {:ok, new_state} 254 | end 255 | 256 | def handle_event({:guild_create, payload}, state) do 257 | Horde.Supervisor.start_child(DiscordGatewayGs.DistributedSupervisor, %{id: Integer.to_string(payload.data.id), start: {DiscordGatewayGs.RedisSync, :start_link, [payload.data.id]}}) 258 | Task.start fn -> 259 | GenServer.cast(:local_redis_client, {:custom, ["LREM", "#{state[:bot_id]}_guilds", "0", payload.data.id]}) 260 | GenServer.cast(:local_redis_client, {:custom, ["LPUSH", "#{state[:bot_id]}_guilds", payload.data.id]}) 261 | DiscordGatewayGs.RedisSync.guild_create(payload.data) 262 | end 263 | #if(payload.data[:id] == 535097935923380244) do 264 | # #IO.inspect(payload) 265 | #end 266 | 267 | {:ok, state} 268 | end 269 | 270 | #def handle_event({:voice_state_update, payload}, state) do 271 | # new_state = _update_voice_state(state, payload) 272 | # {:ok, new_state} 273 | #end 274 | 275 | def handle_event({event, payload}, state) do 276 | if event in state[:gwe_list] do 277 | state[:gwe_map] 278 | |> Enum.filter(fn {_, v} -> 279 | event in v 280 | end) 281 | |> Enum.each(fn {k, _} -> 282 | Task.start fn -> 283 | DiscordGatewayGs.ModuleExecutor.ModuleMap.modules[k].handle_event(event, payload, Integer.to_string(state[:bot_id])) 284 | end 285 | end) 286 | end 287 | 288 | case event do 289 | :message_create -> 290 | Task.start fn -> 291 | DiscordGatewayGs.Statix.increment_messages 292 | DiscordGatewayGs.ModuleExecutor.CommandCenter2.handle_command(payload, {state[:token], state[:bot_id]}) 293 | end 294 | :voice_state_update -> 295 | Task.start fn -> 296 | DiscordGatewayGs.RedisSync.voice_state_update(payload) 297 | end 298 | :guild_member_update -> 299 | Task.start fn -> 300 | DiscordGatewayGs.RedisSync.guild_member_update(payload) 301 | end 302 | e when e == :guild_role_create or e == :guild_role_update -> 303 | Task.start fn -> 304 | DiscordGatewayGs.RedisSync.guild_role_create_or_update(payload) 305 | end 306 | :guild_member_add -> 307 | Task.start fn -> 308 | DiscordGatewayGs.RedisSync.guild_member_add(payload) 309 | end 310 | :guild_member_remove -> 311 | Task.start fn -> 312 | DiscordGatewayGs.RedisSync.guild_member_remove(payload) 313 | end 314 | :guild_delete -> 315 | if(!payload.data["unavailable"]) do 316 | GenServer.cast(:local_redis_client, {:custom, ["LREM", "#{state[:bot_id]}_guilds", "0", payload.data["id"]]}) 317 | end 318 | _ -> {:nostate} 319 | end 320 | {:ok, state} 321 | end 322 | 323 | def identify(state) do 324 | data = %{ 325 | "token" => state[:token], 326 | "properties" => %{ 327 | "$os" => "erlang-vm", 328 | "$browser" => "cloudcord-worker", 329 | "$device" => "cloudcord-genserver", 330 | "$referrer" => "", 331 | "$referring_domain" => "" 332 | }, 333 | "presence" => %{ 334 | "since" => nil, 335 | "game" => %{ 336 | #"name" => "cloudcord.io", 337 | "name" => state[:presence]["message"], 338 | "type" => 0 339 | }, 340 | "status" => "online", 341 | }, 342 | "compress" => false, 343 | "large_threshold" => 250 344 | } 345 | payload = payload_build(opcode(opcodes(), :identify), data) 346 | :websocket_client.cast(self(), {:binary, payload}) 347 | end 348 | 349 | @spec socket_url(map) :: String.t 350 | def socket_url(opts) do 351 | version = opts[:version] || 6 352 | url = DiscordEx.RestClient.resource(opts[:rest_client], :get, "gateway")["url"] 353 | |> String.replace("gg/", "") 354 | String.to_charlist(url <> "?v=#{version}&encoding=etf") 355 | end 356 | 357 | defp _update_agent_sequence(data, state) do 358 | if state[:agent_seq_num] && data.seq_num do 359 | agent_update(state[:agent_seq_num], data.seq_num) 360 | end 361 | end 362 | 363 | defp _static_event?(event) do 364 | Enum.find(@static_events, fn(e) -> e == event end) 365 | end 366 | 367 | defp _update_voice_state(current_state, payload) do 368 | current_state 369 | end 370 | 371 | def _voice_setup_gather_data(caller_pid, data \\ %{}, state) do 372 | new_data = receive do 373 | {client_pid, received_data, _state} -> 374 | data 375 | |> Map.merge(received_data[:data]) 376 | |> Map.merge(%{client_pid: client_pid}) 377 | end 378 | 379 | voice_token = new_data[:token] || state[:voice_token] 380 | endpoint = new_data[:endpoint] || state[:endpoint] 381 | 382 | IO.puts inspect(new_data) 383 | if voice_token && new_data[:session_id] && endpoint do 384 | send(new_data[:client_pid], {:update_state, %{endpoint: endpoint, voice_token: voice_token}}) 385 | send(new_data[:client_pid], {:clear_from_state, [:voice_setup]}) 386 | send(caller_pid, Map.merge(new_data, %{endpoint: endpoint, token: voice_token})) 387 | else 388 | _voice_setup_gather_data(caller_pid, new_data, state) 389 | end 390 | end 391 | 392 | 393 | def _voice_valid_event(event, data, state) do 394 | event = Enum.find([:voice_server_update, :voice_state_update], fn(e) -> e == event end) 395 | case event do 396 | :voice_state_update -> 397 | IO.puts inspect(data) 398 | :voice_server_update -> true 399 | _ -> false 400 | end 401 | end 402 | 403 | @doc """ 404 | Changes your status 405 | ## Parameters 406 | - new_status: map with new status 407 | Supported keys in that map: `:idle_since` and `:game_name`. 408 | If some of them are missing `nil` is used. 409 | ## Examples 410 | new_status = %{idle_since: 123456, game_name: "some game"} 411 | Client.status_update(state, new_status) 412 | """ 413 | #@spec status_update(map, map) :: nil 414 | #def status_update(state, new_status) do 415 | # idle_since = Map.get(new_status, :idle_since) 416 | # game_name = Map.get(new_status, :game_name) 417 | # data = %{"idle_since" => idle_since, "game" => %{"name" => game_name}} 418 | # send(state[:client_pid], {:update_status, data}) 419 | # nil 420 | #end 421 | end 422 | --------------------------------------------------------------------------------