├── .env.example ├── .gitignore ├── shell.nix ├── .github └── workflows │ └── test.yml ├── flake.nix ├── src ├── discord_gleam │ ├── discord │ │ ├── snowflake.gleam │ │ └── intents.gleam │ ├── ws │ │ ├── packets │ │ │ ├── generic.gleam │ │ │ ├── presence_update.gleam │ │ │ ├── channel_update.gleam │ │ │ ├── channel_create.gleam │ │ │ ├── channel_delete.gleam │ │ │ ├── hello.gleam │ │ │ ├── guild_member_remove.gleam │ │ │ ├── guild_ban_remove.gleam │ │ │ ├── guild_role_create.gleam │ │ │ ├── guild_role_update.gleam │ │ │ ├── guild_role_delete.gleam │ │ │ ├── message_delete.gleam │ │ │ ├── guild_member_add.gleam │ │ │ ├── guild_member_update.gleam │ │ │ ├── message_delete_bulk.gleam │ │ │ ├── identify.gleam │ │ │ ├── guild_ban_add.gleam │ │ │ ├── message_update.gleam │ │ │ ├── ready.gleam │ │ │ ├── message.gleam │ │ │ ├── guild_members_chunk.gleam │ │ │ └── interaction_create.gleam │ │ ├── commands │ │ │ └── request_guild_members.gleam │ │ └── event_loop.gleam │ ├── types │ │ ├── guild.gleam │ │ ├── bot.gleam │ │ ├── reply.gleam │ │ ├── message.gleam │ │ ├── message_send_response.gleam │ │ ├── presence.gleam │ │ ├── role.gleam │ │ ├── user.gleam │ │ ├── channel.gleam │ │ ├── guild_member.gleam │ │ ├── slash_command.gleam │ │ └── activity.gleam │ ├── internal │ │ └── error.gleam │ ├── http │ │ ├── request.gleam │ │ └── endpoints.gleam │ └── event_handler.gleam └── discord_gleam.gleam ├── test ├── discord_gleam_test.gleam └── example_bot.gleam ├── flake.lock ├── gleam.toml ├── LICENSE ├── examples ├── pingpong.gleam ├── embed.gleam ├── delete_message.gleam ├── kick.gleam ├── ban.gleam └── slash_commands.gleam ├── README.md └── manifest.toml /.env.example: -------------------------------------------------------------------------------- 1 | TEST_BOT_TOKEN= 2 | TEST_BOT_CLIENT_ID= 3 | TEST_BOT_GUILD_ID= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | .env 6 | .env.local 7 | .direnv 8 | .envrc 9 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: 2 | pkgs.mkShell { 3 | nativeBuildInputs = with pkgs; [ 4 | gleam 5 | erlang_28 6 | beam28Packages.rebar3 7 | ]; 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: erlef/setup-beam@v1 13 | with: 14 | otp-version: "26.0.2" 15 | gleam-version: "1.12.0" 16 | rebar3-version: "3" 17 | - run: gleam deps download 18 | - run: gleam test 19 | - run: gleam format --check src test 20 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 | }; 5 | 6 | outputs = {nixpkgs, ...}: let 7 | supportedSystems = ["x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"]; 8 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 9 | nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); 10 | in { 11 | devShells = forAllSystems (system: let 12 | pkgs = nixpkgsFor.${system}; 13 | in { 14 | default = import ./shell.nix {inherit pkgs;}; 15 | }); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/discord_gleam/discord/snowflake.gleam: -------------------------------------------------------------------------------- 1 | //// Snowflakes is discord's type for unique identifiers. \ 2 | //// They are 64-bit unsigned integers, represented as strings. \ 3 | //// See https://discord.com/developers/docs/reference#snowflakes 4 | 5 | import gleam/dynamic/decode 6 | import gleam/int 7 | 8 | /// We are representing Discord's snowflake as a string 9 | pub type Snowflake = 10 | String 11 | 12 | /// API should not give a int, but incase it does we will convert to string. 13 | pub fn decoder() { 14 | decode.one_of(decode.string, [decode.int |> decode.map(int.to_string)]) 15 | } 16 | -------------------------------------------------------------------------------- /test/discord_gleam_test.gleam: -------------------------------------------------------------------------------- 1 | import example_bot 2 | import gleam/result 3 | import glenvy/dotenv 4 | import glenvy/env 5 | 6 | pub fn main() { 7 | let _ = dotenv.load() 8 | 9 | case 10 | { 11 | use token <- result.try(env.string("TEST_BOT_TOKEN")) 12 | use client_id <- result.try(env.string("TEST_BOT_CLIENT_ID")) 13 | use guild_id <- result.try(env.string("TEST_BOT_GUILD_ID")) 14 | 15 | Ok(example_bot.main(token, client_id, guild_id)) 16 | } 17 | { 18 | Ok(_) -> Nil 19 | Error(msg) -> { 20 | echo msg 21 | Nil 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1758701979, 6 | "narHash": "sha256-c7DUti3XM1aga8oVgaPnrVmEeCFtN9PaBxyNuqx8jPc=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "e2642aa7d5a15eae586932a56f4294934f959c14", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/generic.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic/decode 2 | import gleam/json 3 | 4 | /// And packet excluding the data object d: json 5 | pub type GenericPacket { 6 | GenericPacket(t: String, s: Int, op: Int) 7 | } 8 | 9 | pub fn string_to_data(encoded: String) -> GenericPacket { 10 | let decoder = { 11 | use t <- decode.field("t", decode.string) 12 | use s <- decode.field("s", decode.int) 13 | use op <- decode.field("op", decode.int) 14 | decode.success(GenericPacket(t:, s:, op:)) 15 | } 16 | 17 | let data = json.parse(from: encoded, using: decoder) 18 | 19 | case data { 20 | Ok(decoded) -> decoded 21 | Error(_) -> GenericPacket("error", 0, 0) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/discord_gleam/types/guild.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import gleam/dynamic/decode 3 | 4 | /// See https://discord.com/developers/docs/resources/guild#guild-resource \ 5 | pub type Guild { 6 | UnavailableGuild(id: Snowflake, unavailable: Bool) 7 | // TODO: Implement guild structure 8 | Guild(id: Snowflake) 9 | } 10 | 11 | pub fn from_json_decoder() -> decode.Decoder(Guild) { 12 | use id <- decode.field("id", snowflake.decoder()) 13 | use unavailable <- decode.optional_field("unavailable", False, decode.bool) 14 | 15 | case unavailable { 16 | True -> decode.success(UnavailableGuild(id:, unavailable:)) 17 | False -> decode.success(Guild(id:)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/presence_update.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/types/presence 2 | import gleam/dynamic/decode 3 | import gleam/json 4 | 5 | pub type PresenceUpdatePacket { 6 | PresenceUpdatePacket(t: String, s: Int, op: Int, d: presence.Presence) 7 | } 8 | 9 | pub fn string_to_data( 10 | encoded: String, 11 | ) -> Result(PresenceUpdatePacket, json.DecodeError) { 12 | let decoder = { 13 | use t <- decode.field("t", decode.string) 14 | use s <- decode.field("s", decode.int) 15 | use op <- decode.field("op", decode.int) 16 | use d <- decode.field("d", presence.from_json_decoder()) 17 | decode.success(PresenceUpdatePacket(t:, s:, op:, d:)) 18 | } 19 | 20 | json.parse(from: encoded, using: decoder) 21 | } 22 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/channel_update.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/types/channel 2 | import gleam/dynamic/decode 3 | import gleam/json 4 | 5 | // Packet sent by Discord when a channel is updated 6 | pub type ChannelUpdatePacket { 7 | ChannelUpdatePacket(t: String, s: Int, op: Int, d: channel.Channel) 8 | } 9 | 10 | pub fn string_to_data( 11 | encoded: String, 12 | ) -> Result(ChannelUpdatePacket, json.DecodeError) { 13 | let decoder = { 14 | use t <- decode.field("t", decode.string) 15 | use s <- decode.field("s", decode.int) 16 | use op <- decode.field("op", decode.int) 17 | use d <- decode.field("d", channel.from_json_decoder()) 18 | decode.success(ChannelUpdatePacket(t:, s:, op:, d:)) 19 | } 20 | 21 | json.parse(from: encoded, using: decoder) 22 | } 23 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/channel_create.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/types/channel 2 | import gleam/dynamic/decode 3 | import gleam/json 4 | 5 | /// Packet sent by Discord when a channel is created 6 | pub type ChannelCreatePacket { 7 | ChannelCreatePacket(t: String, s: Int, op: Int, d: channel.Channel) 8 | } 9 | 10 | pub fn string_to_data( 11 | encoded: String, 12 | ) -> Result(ChannelCreatePacket, json.DecodeError) { 13 | let decoder = { 14 | use t <- decode.field("t", decode.string) 15 | use s <- decode.field("s", decode.int) 16 | use op <- decode.field("op", decode.int) 17 | use d <- decode.field("d", channel.from_json_decoder()) 18 | decode.success(ChannelCreatePacket(t:, s:, op:, d:)) 19 | } 20 | 21 | json.parse(from: encoded, using: decoder) 22 | } 23 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/channel_delete.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/types/channel 2 | import gleam/dynamic/decode 3 | import gleam/json 4 | 5 | /// Packet sent by Discord when a channel is deleted 6 | pub type ChannelDeletePacket { 7 | ChannelDeletePacket(t: String, s: Int, op: Int, d: channel.Channel) 8 | } 9 | 10 | pub fn string_to_data( 11 | encoded: String, 12 | ) -> Result(ChannelDeletePacket, json.DecodeError) { 13 | let decoder = { 14 | use t <- decode.field("t", decode.string) 15 | use s <- decode.field("s", decode.int) 16 | use op <- decode.field("op", decode.int) 17 | use d <- decode.field("d", channel.from_json_decoder()) 18 | decode.success(ChannelDeletePacket(t:, s:, op:, d:)) 19 | } 20 | 21 | json.parse(from: encoded, using: decoder) 22 | } 23 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/hello.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic/decode 2 | import gleam/json 3 | 4 | pub type HelloPacketData { 5 | HelloPacketData(heartbeat_interval: Int) 6 | } 7 | 8 | /// Packet returned by discord upon connecting to the gateway 9 | pub type HelloPacket { 10 | HelloPacket(op: Int, d: HelloPacketData) 11 | } 12 | 13 | pub fn string_to_data(encoded: String) -> Result(HelloPacket, json.DecodeError) { 14 | let decoder = { 15 | use op <- decode.field("op", decode.int) 16 | use d <- decode.field("d", { 17 | use heartbeat_interval <- decode.field("heartbeat_interval", decode.int) 18 | decode.success(HelloPacketData(heartbeat_interval:)) 19 | }) 20 | decode.success(HelloPacket(op:, d:)) 21 | } 22 | 23 | json.parse(from: encoded, using: decoder) 24 | } 25 | -------------------------------------------------------------------------------- /src/discord_gleam/types/bot.gleam: -------------------------------------------------------------------------------- 1 | import booklet 2 | import discord_gleam/discord/intents 3 | import discord_gleam/discord/snowflake.{type Snowflake} 4 | import discord_gleam/ws/packets/message.{type MessagePacketData} 5 | import gleam/dict 6 | import gleam/erlang/process 7 | 8 | /// The Bot type holds bot data used by a lot of high-level functions 9 | pub type Bot { 10 | Bot( 11 | token: String, 12 | client_id: Snowflake, 13 | intents: intents.Intents, 14 | cache: Cache, 15 | subject: process.Subject(BotMessage), 16 | ) 17 | } 18 | 19 | /// Used to send user messages to the websocket process 20 | pub type BotMessage { 21 | SendPacket(packet: String) 22 | } 23 | 24 | /// The cache currently only stores messages, which can be used to for example get deleted messages 25 | pub type Cache { 26 | Cache(messages: booklet.Booklet(dict.Dict(Snowflake, MessagePacketData))) 27 | } 28 | -------------------------------------------------------------------------------- /src/discord_gleam/types/reply.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/types/message.{type Embed} 2 | import gleam/json 3 | import gleam/list 4 | 5 | /// Our reply type, which is used to send replies to messages 6 | pub type Reply { 7 | Reply(content: String, message_id: String, embeds: List(Embed)) 8 | } 9 | 10 | /// Convert a reply to a JSON string 11 | pub fn to_string(msg: Reply) -> String { 12 | let embeds_json = list.map(msg.embeds, embed_to_json) 13 | json.object([ 14 | #("content", json.string(msg.content)), 15 | #("embeds", json.array(embeds_json, of: fn(x) { x })), 16 | #( 17 | "message_reference", 18 | json.object([#("message_id", json.string(msg.message_id))]), 19 | ), 20 | ]) 21 | |> json.to_string 22 | } 23 | 24 | /// Convert an embed to a JSON object 25 | pub fn embed_to_json(embed: Embed) -> json.Json { 26 | json.object([ 27 | #("title", json.string(embed.title)), 28 | #("description", json.string(embed.description)), 29 | #("color", json.int(embed.color)), 30 | ]) 31 | } 32 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "discord_gleam" 2 | version = "2.1.1" 3 | 4 | # Fill out these fields if you intend to generate HTML documentation or publish 5 | # your project to the Hex package manager. 6 | 7 | description = "discord_gleam is a (unfinished) library for making discord bots" 8 | licences = ["MIT"] 9 | repository = { type = "github", user = "cyteon", repo = "discord_gleam" } 10 | # links = [{ title = "Website", href = "" }] 11 | # 12 | # For a full reference of all the available options, you can have a look at 13 | # https://gleam.run/writing-gleam/gleam-toml/. 14 | 15 | gleam = ">= 1.11.0" 16 | 17 | [dependencies] 18 | gleam_stdlib = ">= 0.60.0 and < 1.0.0" 19 | gleam_erlang = ">= 1.0.0 and < 2.0.0" 20 | gleam_http = ">= 4.0.0 and < 5.0.0" 21 | gleam_json = ">= 3.0.1 and < 4.0.0" 22 | gleam_otp = ">= 1.0.0 and < 2.0.0" 23 | logging = ">= 1.3.0 and < 2.0.0" 24 | repeatedly = ">= 2.1.2 and < 3.0.0" 25 | glenvy = ">= 2.0.1" 26 | booklet = ">= 1.0.2 and < 2.0.0" 27 | discord_gleam_stratus = ">= 1.0.2 and < 2.0.0" 28 | gleam_httpc = ">= 5.0.0 and < 6.0.0" 29 | 30 | [dev-dependencies] 31 | gleeunit = ">= 1.5.1 and < 2.0.0" 32 | -------------------------------------------------------------------------------- /src/discord_gleam/types/message.gleam: -------------------------------------------------------------------------------- 1 | import gleam/json 2 | import gleam/list 3 | 4 | /// An embed, simplified for now. \ 5 | /// See https://discord.com/developers/docs/resources/channel#embed-object 6 | pub type Embed { 7 | Embed(title: String, description: String, color: Int) 8 | // TODO: add more fields 9 | } 10 | 11 | /// Our message type, holds content and embeds and is passed to the low-level networking functions 12 | pub type Message { 13 | Message(content: String, embeds: List(Embed)) 14 | } 15 | 16 | /// Convert a message to a JSON string 17 | pub fn to_string(msg: Message) -> String { 18 | let embeds_json = list.map(msg.embeds, embed_to_json) 19 | json.object([ 20 | #("content", json.string(msg.content)), 21 | #("embeds", json.array(embeds_json, of: fn(x) { x })), 22 | ]) 23 | |> json.to_string 24 | } 25 | 26 | /// Convert an embed to a JSON object 27 | pub fn embed_to_json(embed: Embed) -> json.Json { 28 | json.object([ 29 | #("title", json.string(embed.title)), 30 | #("description", json.string(embed.description)), 31 | #("color", json.int(embed.color)), 32 | ]) 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Cyteon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_member_remove.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/user 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | 6 | pub type GuildMemberRemoveData { 7 | GuildMemberRemoveData(user: user.User, guild_id: Snowflake) 8 | } 9 | 10 | /// Packet sent by Discord when a member is removed from a guild 11 | pub type GuildMemberRemove { 12 | GuildMemberRemove(t: String, s: Int, op: Int, d: GuildMemberRemoveData) 13 | } 14 | 15 | pub fn string_to_data( 16 | encoded: String, 17 | ) -> Result(GuildMemberRemove, json.DecodeError) { 18 | let decoder = { 19 | use t <- decode.field("t", decode.string) 20 | use s <- decode.field("s", decode.int) 21 | use op <- decode.field("op", decode.int) 22 | use d <- decode.field("d", { 23 | use user <- decode.field("user", user.from_json_decoder()) 24 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 25 | decode.success(GuildMemberRemoveData(user:, guild_id:)) 26 | }) 27 | decode.success(GuildMemberRemove(t:, s:, op:, d:)) 28 | } 29 | 30 | json.parse(from: encoded, using: decoder) 31 | } 32 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_ban_remove.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/user 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | 6 | pub type GuildBanRemovePacketData { 7 | GuildBanRemovePacketData(user: user.User, guild_id: Snowflake) 8 | } 9 | 10 | /// Packet sent by Discord when a member is unbanned 11 | pub type GuildBanRemovePacket { 12 | GuildBanRemovePacket(t: String, s: Int, op: Int, d: GuildBanRemovePacketData) 13 | } 14 | 15 | pub fn string_to_data( 16 | encoded: String, 17 | ) -> Result(GuildBanRemovePacket, json.DecodeError) { 18 | let decoder = { 19 | use t <- decode.field("t", decode.string) 20 | use s <- decode.field("s", decode.int) 21 | use op <- decode.field("op", decode.int) 22 | use d <- decode.field("d", { 23 | use user <- decode.field("user", user.from_json_decoder()) 24 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 25 | decode.success(GuildBanRemovePacketData(user:, guild_id:)) 26 | }) 27 | decode.success(GuildBanRemovePacket(t:, s:, op:, d:)) 28 | } 29 | 30 | json.parse(from: encoded, using: decoder) 31 | } 32 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_role_create.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake 2 | import discord_gleam/types/role 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | 6 | /// Packet sent by Discord when a role is created 7 | pub type GuildRoleCreatePacket { 8 | GuildRoleCreatePacket( 9 | t: String, 10 | s: Int, 11 | op: Int, 12 | d: GuildRoleCreatePacketData, 13 | ) 14 | } 15 | 16 | pub type GuildRoleCreatePacketData { 17 | GuildRoleCreatePacketData(guild_id: snowflake.Snowflake, role: role.Role) 18 | } 19 | 20 | pub fn string_to_data( 21 | encoded: String, 22 | ) -> Result(GuildRoleCreatePacket, json.DecodeError) { 23 | let decoder = { 24 | use t <- decode.field("t", decode.string) 25 | use s <- decode.field("s", decode.int) 26 | use op <- decode.field("op", decode.int) 27 | 28 | use d <- decode.field("d", { 29 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 30 | use role <- decode.field("role", role.from_json_decoder()) 31 | 32 | decode.success(GuildRoleCreatePacketData(guild_id:, role:)) 33 | }) 34 | 35 | decode.success(GuildRoleCreatePacket(t:, s:, op:, d:)) 36 | } 37 | 38 | json.parse(from: encoded, using: decoder) 39 | } 40 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_role_update.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake 2 | import discord_gleam/types/role 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | 6 | /// Packet sent by Discord when a role is updated 7 | pub type GuildRoleUpdatePacket { 8 | GuildRoleUpdatePacket( 9 | t: String, 10 | s: Int, 11 | op: Int, 12 | d: GuildRoleUpdatePacketData, 13 | ) 14 | } 15 | 16 | pub type GuildRoleUpdatePacketData { 17 | GuildRoleUpdatePacketData(guild_id: snowflake.Snowflake, role: role.Role) 18 | } 19 | 20 | pub fn string_to_data( 21 | encoded: String, 22 | ) -> Result(GuildRoleUpdatePacket, json.DecodeError) { 23 | let decoder = { 24 | use t <- decode.field("t", decode.string) 25 | use s <- decode.field("s", decode.int) 26 | use op <- decode.field("op", decode.int) 27 | 28 | use d <- decode.field("d", { 29 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 30 | use role <- decode.field("role", role.from_json_decoder()) 31 | 32 | decode.success(GuildRoleUpdatePacketData(guild_id:, role:)) 33 | }) 34 | 35 | decode.success(GuildRoleUpdatePacket(t:, s:, op:, d:)) 36 | } 37 | 38 | json.parse(from: encoded, using: decoder) 39 | } 40 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_role_delete.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake 2 | import gleam/dynamic/decode 3 | import gleam/json 4 | 5 | /// Packet sent by Discord when a role is deleted 6 | pub type GuildRoleDeletePacket { 7 | GuildRoleDeletePacket( 8 | t: String, 9 | s: Int, 10 | op: Int, 11 | d: GuildRoleDeletePacketData, 12 | ) 13 | } 14 | 15 | pub type GuildRoleDeletePacketData { 16 | GuildRoleDeletePacketData( 17 | guild_id: snowflake.Snowflake, 18 | role_id: snowflake.Snowflake, 19 | ) 20 | } 21 | 22 | pub fn string_to_data( 23 | encoded: String, 24 | ) -> Result(GuildRoleDeletePacket, json.DecodeError) { 25 | let decoder = { 26 | use t <- decode.field("t", decode.string) 27 | use s <- decode.field("s", decode.int) 28 | use op <- decode.field("op", decode.int) 29 | 30 | use d <- decode.field("d", { 31 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 32 | use role_id <- decode.field("role_id", snowflake.decoder()) 33 | decode.success(GuildRoleDeletePacketData(guild_id:, role_id:)) 34 | }) 35 | 36 | decode.success(GuildRoleDeletePacket(t:, s:, op:, d:)) 37 | } 38 | 39 | json.parse(from: encoded, using: decoder) 40 | } 41 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/message_delete.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import gleam/dynamic/decode 3 | import gleam/json 4 | 5 | pub type MessageDeletePacketData { 6 | MessageDeletePacketData( 7 | id: Snowflake, 8 | guild_id: Snowflake, 9 | channel_id: Snowflake, 10 | ) 11 | } 12 | 13 | /// Packet sent by Discord when a message is deleted 14 | pub type MessageDeletePacket { 15 | MessageDeletePacket(t: String, s: Int, op: Int, d: MessageDeletePacketData) 16 | } 17 | 18 | pub fn string_to_data( 19 | encoded: String, 20 | ) -> Result(MessageDeletePacket, json.DecodeError) { 21 | let decoder = { 22 | use t <- decode.field("t", decode.string) 23 | use s <- decode.field("s", decode.int) 24 | use op <- decode.field("op", decode.int) 25 | use d <- decode.field("d", { 26 | use id <- decode.field("id", snowflake.decoder()) 27 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 28 | use channel_id <- decode.field("channel_id", snowflake.decoder()) 29 | decode.success(MessageDeletePacketData(id:, guild_id:, channel_id:)) 30 | }) 31 | decode.success(MessageDeletePacket(t:, s:, op:, d:)) 32 | } 33 | 34 | json.parse(from: encoded, using: decoder) 35 | } 36 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_member_add.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/guild_member 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | 6 | pub type GuildMemberAddData { 7 | GuildMemberAddData( 8 | guild_member: guild_member.GuildMember, 9 | guild_id: Snowflake, 10 | ) 11 | } 12 | 13 | /// Packet sent by Discord when a member is added to a guild 14 | pub type GuildMemberAdd { 15 | GuildMemberAdd(t: String, s: Int, op: Int, d: GuildMemberAddData) 16 | } 17 | 18 | pub fn string_to_data( 19 | encoded: String, 20 | ) -> Result(GuildMemberAdd, json.DecodeError) { 21 | let decoder = { 22 | use t <- decode.field("t", decode.string) 23 | use s <- decode.field("s", decode.int) 24 | use op <- decode.field("op", decode.int) 25 | use guild_member <- decode.field("d", guild_member.from_json_decoder()) 26 | use guild_id <- decode.field("d", { 27 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 28 | decode.success(guild_id) 29 | }) 30 | 31 | decode.success(GuildMemberAdd( 32 | t:, 33 | s:, 34 | op:, 35 | d: GuildMemberAddData(guild_member:, guild_id:), 36 | )) 37 | } 38 | 39 | json.parse(from: encoded, using: decoder) 40 | } 41 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_member_update.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/guild_member 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | 6 | pub type GuildMemberUpdateData { 7 | GuildMemberUpdateData( 8 | guild_member: guild_member.GuildMember, 9 | guild_id: Snowflake, 10 | ) 11 | } 12 | 13 | /// Packet sent by Discord when a member is added to a guild 14 | pub type GuildMemberUpdate { 15 | GuildMemberUpdate(t: String, s: Int, op: Int, d: GuildMemberUpdateData) 16 | } 17 | 18 | pub fn string_to_data( 19 | encoded: String, 20 | ) -> Result(GuildMemberUpdate, json.DecodeError) { 21 | let decoder = { 22 | use t <- decode.field("t", decode.string) 23 | use s <- decode.field("s", decode.int) 24 | use op <- decode.field("op", decode.int) 25 | use guild_member <- decode.field("d", guild_member.from_json_decoder()) 26 | use guild_id <- decode.field("d", { 27 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 28 | decode.success(guild_id) 29 | }) 30 | 31 | decode.success(GuildMemberUpdate( 32 | t:, 33 | s:, 34 | op:, 35 | d: GuildMemberUpdateData(guild_member:, guild_id:), 36 | )) 37 | } 38 | 39 | json.parse(from: encoded, using: decoder) 40 | } 41 | -------------------------------------------------------------------------------- /examples/pingpong.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam 2 | import discord_gleam/discord/intents 3 | import discord_gleam/event_handler 4 | import discord_gleam/types/message 5 | import gleam/erlang/process 6 | import gleam/otp/static_supervisor as supervisor 7 | import gleam/otp/supervision 8 | import gleam/list 9 | import gleam/string 10 | import logging 11 | 12 | pub fn main() { 13 | logging.configure() 14 | logging.set_level(logging.Info) 15 | 16 | let bot = discord_gleam.bot("token", "client id", intents.default()) 17 | 18 | let bot = 19 | supervision.worker(fn() { 20 | discord_gleam.simple(bot, [simple_handler]) 21 | |> discord_gleam.start() 22 | }) 23 | 24 | let assert Ok(_) = 25 | supervisor.new(supervisor.OneForOne) 26 | |> supervisor.add(bot) 27 | |> supervisor.start() 28 | 29 | process.sleep_forever() 30 | } 31 | 32 | fn simple_handler(bot, packet: event_handler.Packet) { 33 | case packet { 34 | event_handler.MessagePacket(message) -> { 35 | logging.log(logging.Info, "Message: " <> message.d.content) 36 | 37 | case message.d.content { 38 | "!ping" -> { 39 | discord_gleam.send_message(bot, message.d.channel_id, "Pong!", []) 40 | 41 | Nil 42 | } 43 | _ -> Nil 44 | } 45 | } 46 | _ -> Nil 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/discord_gleam/types/message_send_response.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/internal/error 3 | import discord_gleam/types/user 4 | import gleam/dynamic/decode 5 | import gleam/json 6 | import gleam/result 7 | 8 | /// Data returned by discord when you send a message 9 | pub type MessageSendResponse { 10 | MessageSendResponse( 11 | id: Snowflake, 12 | channel_id: Snowflake, 13 | content: String, 14 | timestamp: String, 15 | author: user.User, 16 | ) 17 | } 18 | 19 | /// Decode a string to a message send response 20 | pub fn from_json_string( 21 | encoded: String, 22 | ) -> Result(MessageSendResponse, error.DiscordError) { 23 | let decoder = { 24 | use id <- decode.field("id", snowflake.decoder()) 25 | use channel_id <- decode.field("channel_id", snowflake.decoder()) 26 | use content <- decode.field("content", decode.string) 27 | use timestamp <- decode.field("timestamp", decode.string) 28 | use author <- decode.field("author", user.from_json_decoder()) 29 | decode.success(MessageSendResponse( 30 | id:, 31 | channel_id:, 32 | content:, 33 | timestamp:, 34 | author:, 35 | )) 36 | } 37 | 38 | json.parse(from: encoded, using: decoder) 39 | |> result.map_error(error.JsonDecodeError) 40 | } 41 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/message_delete_bulk.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import gleam/dynamic/decode 3 | import gleam/json 4 | 5 | pub type MessageDeleteBulkPacketData { 6 | MessageDeleteBulkPacketData( 7 | ids: List(Snowflake), 8 | guild_id: Snowflake, 9 | channel_id: Snowflake, 10 | ) 11 | } 12 | 13 | /// Packet sent by Discord when messages are bulk deleted 14 | pub type MessageDeleteBulkPacket { 15 | MessageDeleteBulkPacket( 16 | t: String, 17 | s: Int, 18 | op: Int, 19 | d: MessageDeleteBulkPacketData, 20 | ) 21 | } 22 | 23 | pub fn string_to_data( 24 | encoded: String, 25 | ) -> Result(MessageDeleteBulkPacket, json.DecodeError) { 26 | let decoder = { 27 | use t <- decode.field("t", decode.string) 28 | use s <- decode.field("s", decode.int) 29 | use op <- decode.field("op", decode.int) 30 | use d <- decode.field("d", { 31 | use ids <- decode.field("ids", decode.list(snowflake.decoder())) 32 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 33 | use channel_id <- decode.field("channel_id", snowflake.decoder()) 34 | decode.success(MessageDeleteBulkPacketData(ids:, guild_id:, channel_id:)) 35 | }) 36 | decode.success(MessageDeleteBulkPacket(t:, s:, op:, d:)) 37 | } 38 | 39 | json.parse(from: encoded, using: decoder) 40 | } 41 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/identify.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/intents 2 | import gleam/int 3 | import gleam/json 4 | 5 | pub fn create_packet(token: String, intents: intents.Intents) -> String { 6 | json.object([ 7 | #("op", json.int(2)), 8 | #( 9 | "d", 10 | json.object([ 11 | #("token", json.string(token)), 12 | #("intents", json.int(intents.intents_to_bitfield(intents))), 13 | #( 14 | "properties", 15 | json.object([ 16 | #("os", json.string("gleam")), 17 | #("browser", json.string("discord_gleam")), 18 | #("device", json.string("discord_gleam")), 19 | ]), 20 | ), 21 | ]), 22 | ), 23 | ]) 24 | |> json.to_string 25 | } 26 | 27 | pub fn create_resume_packet( 28 | token: String, 29 | intents: intents.Intents, 30 | session_id: String, 31 | sequence: String, 32 | ) -> String { 33 | json.object([ 34 | #("op", json.int(6)), 35 | #( 36 | "d", 37 | json.object([ 38 | #("token", json.string(token)), 39 | #("session_id", json.string(session_id)), 40 | #( 41 | "seq", 42 | json.int(case int.parse(sequence) { 43 | Ok(s) -> s 44 | Error(_) -> 0 45 | }), 46 | ), 47 | #("intents", json.int(intents.intents_to_bitfield(intents))), 48 | ]), 49 | ), 50 | ]) 51 | |> json.to_string 52 | } 53 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_ban_add.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/user 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | import gleam/option 6 | 7 | pub type GuildBanAddPacketData { 8 | GuildBanAddPacketData( 9 | user: user.User, 10 | guild_id: Snowflake, 11 | delete_message_secs: option.Option(Int), 12 | ) 13 | } 14 | 15 | /// Packet sent by Discord when a member is banned 16 | pub type GuildBanAddPacket { 17 | GuildBanAddPacket(t: String, s: Int, op: Int, d: GuildBanAddPacketData) 18 | } 19 | 20 | pub fn string_to_data( 21 | encoded: String, 22 | ) -> Result(GuildBanAddPacket, json.DecodeError) { 23 | let decoder = { 24 | use t <- decode.field("t", decode.string) 25 | use s <- decode.field("s", decode.int) 26 | use op <- decode.field("op", decode.int) 27 | use d <- decode.field("d", { 28 | use user <- decode.field("user", user.from_json_decoder()) 29 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 30 | use delete_message_secs <- decode.optional_field( 31 | "delete_message_seconds", 32 | option.None, 33 | decode.int |> decode.map(option.Some), 34 | ) 35 | decode.success(GuildBanAddPacketData( 36 | user:, 37 | guild_id:, 38 | delete_message_secs:, 39 | )) 40 | }) 41 | decode.success(GuildBanAddPacket(t:, s:, op:, d:)) 42 | } 43 | 44 | json.parse(from: encoded, using: decoder) 45 | } 46 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/message_update.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake 2 | import discord_gleam/types/user 3 | import discord_gleam/ws/packets/message 4 | import gleam/dynamic/decode 5 | import gleam/json 6 | import gleam/option.{None, Some} 7 | 8 | /// Packet sent by Discord when a message is updated 9 | pub type MessageUpdatePacket { 10 | MessageUpdatePacket(t: String, s: Int, op: Int, d: message.MessagePacketData) 11 | } 12 | 13 | pub fn string_to_data( 14 | encoded: String, 15 | ) -> Result(MessageUpdatePacket, json.DecodeError) { 16 | let decoder = { 17 | use t <- decode.field("t", decode.string) 18 | use s <- decode.field("s", decode.int) 19 | use op <- decode.field("op", decode.int) 20 | use d <- decode.field("d", { 21 | use content <- decode.field("content", decode.string) 22 | use id <- decode.field("id", snowflake.decoder()) 23 | use guild_id <- decode.optional_field( 24 | "guild_id", 25 | None, 26 | snowflake.decoder() |> decode.map(Some), 27 | ) 28 | use channel_id <- decode.field("channel_id", snowflake.decoder()) 29 | use author <- decode.field("author", user.from_json_decoder()) 30 | 31 | decode.success(message.MessagePacketData( 32 | content:, 33 | id:, 34 | guild_id:, 35 | channel_id:, 36 | author:, 37 | )) 38 | }) 39 | decode.success(MessageUpdatePacket(t:, s:, op:, d:)) 40 | } 41 | 42 | json.parse(from: encoded, using: decoder) 43 | } 44 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/ready.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/types/guild 2 | import discord_gleam/types/user 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | 6 | pub type ReadyData { 7 | ReadyData( 8 | v: Int, 9 | user: user.User, 10 | guilds: List(guild.Guild), 11 | session_id: String, 12 | resume_gateway_url: String, 13 | ) 14 | } 15 | 16 | // Packet sent by Discord when the client is authenticated and ready 17 | pub type ReadyPacket { 18 | ReadyPacket(t: String, s: Int, op: Int, d: ReadyData) 19 | } 20 | 21 | pub fn string_to_data(encoded: String) -> Result(ReadyPacket, json.DecodeError) { 22 | let decoder = { 23 | use t <- decode.field("t", decode.string) 24 | use s <- decode.field("s", decode.int) 25 | use op <- decode.field("op", decode.int) 26 | 27 | use d <- decode.field("d", { 28 | use v <- decode.field("v", decode.int) 29 | 30 | use user <- decode.field("user", user.from_json_decoder()) 31 | 32 | use guilds <- decode.field( 33 | "guilds", 34 | decode.list(guild.from_json_decoder()), 35 | ) 36 | 37 | use session_id <- decode.field("session_id", decode.string) 38 | use resume_gateway_url <- decode.field( 39 | "resume_gateway_url", 40 | decode.string, 41 | ) 42 | 43 | decode.success(ReadyData( 44 | v:, 45 | user:, 46 | guilds:, 47 | session_id:, 48 | resume_gateway_url:, 49 | )) 50 | }) 51 | 52 | decode.success(ReadyPacket(t:, s:, op:, d:)) 53 | } 54 | 55 | json.parse(from: encoded, using: decoder) 56 | } 57 | -------------------------------------------------------------------------------- /examples/embed.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam 2 | import discord_gleam/discord/intents 3 | import discord_gleam/event_handler 4 | import discord_gleam/types/message 5 | import gleam/erlang/process 6 | import gleam/otp/static_supervisor as supervisor 7 | import gleam/otp/supervision 8 | import gleam/list 9 | import gleam/string 10 | import logging 11 | 12 | pub fn main() { 13 | logging.configure() 14 | logging.set_level(logging.Info) 15 | 16 | let bot = discord_gleam.bot("token", "client id", intents.default()) 17 | 18 | let bot = 19 | supervision.worker(fn() { 20 | discord_gleam.simple(bot, [simple_handler]) 21 | |> discord_gleam.start() 22 | }) 23 | 24 | let assert Ok(_) = 25 | supervisor.new(supervisor.OneForOne) 26 | |> supervisor.add(bot) 27 | |> supervisor.start() 28 | 29 | process.sleep_forever() 30 | } 31 | 32 | fn simple_handler(bot, packet: event_handler.Packet) { 33 | case packet { 34 | event_handler.ReadyPacket(ready) -> { 35 | logging.log(logging.Info, "Logged in as " <> ready.d.user.username) 36 | 37 | Nil 38 | } 39 | event_handler.MessagePacket(message) -> { 40 | logging.log(logging.Info, "Message: " <> message.d.content) 41 | case message.d.content { 42 | "!embed" -> { 43 | let embed1 = 44 | message.Embed( 45 | title: "Embed Title", 46 | description: "Embed Description", 47 | color: 0x00FF00, 48 | ) 49 | 50 | discord_gleam.send_message(bot, message.d.channel_id, "Embed!", [ 51 | embed1, 52 | ]) 53 | 54 | Nil 55 | } 56 | _ -> Nil 57 | } 58 | } 59 | _ -> Nil 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/message.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/user 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | import gleam/option.{type Option, None, Some} 6 | 7 | /// Represents a message packet data structure, also used on message update 8 | pub type MessagePacketData { 9 | MessagePacketData( 10 | content: String, 11 | id: Snowflake, 12 | guild_id: Option(Snowflake), 13 | channel_id: Snowflake, 14 | author: user.User, 15 | ) 16 | } 17 | 18 | // Packet sent by Discord when a message is sent 19 | pub type MessagePacket { 20 | MessagePacket(t: String, s: Int, op: Int, d: MessagePacketData) 21 | } 22 | 23 | pub fn string_to_data( 24 | encoded: String, 25 | ) -> Result(MessagePacket, json.DecodeError) { 26 | let decoder = { 27 | use t <- decode.field("t", decode.string) 28 | use s <- decode.field("s", decode.int) 29 | use op <- decode.field("op", decode.int) 30 | use d <- decode.field("d", { 31 | use content <- decode.field("content", decode.string) 32 | use id <- decode.field("id", snowflake.decoder()) 33 | use guild_id <- decode.optional_field( 34 | "guild_id", 35 | None, 36 | snowflake.decoder() |> decode.map(Some), 37 | ) 38 | use channel_id <- decode.field("channel_id", snowflake.decoder()) 39 | use author <- decode.field("author", user.from_json_decoder()) 40 | decode.success(MessagePacketData( 41 | content:, 42 | id:, 43 | guild_id:, 44 | channel_id:, 45 | author:, 46 | )) 47 | }) 48 | decode.success(MessagePacket(t:, s:, op:, d:)) 49 | } 50 | 51 | json.parse(from: encoded, using: decoder) 52 | } 53 | -------------------------------------------------------------------------------- /examples/delete_message.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam 2 | import discord_gleam/discord/intents 3 | import discord_gleam/event_handler 4 | import discord_gleam/types/message 5 | import gleam/erlang/process 6 | import gleam/otp/static_supervisor as supervisor 7 | import gleam/otp/supervision 8 | import gleam/list 9 | import gleam/string 10 | import logging 11 | 12 | pub fn main() { 13 | logging.configure() 14 | logging.set_level(logging.Info) 15 | 16 | let bot = discord_gleam.bot("token", "client id", intents.default()) 17 | 18 | let bot = 19 | supervision.worker(fn() { 20 | discord_gleam.simple(bot, [simple_handler]) 21 | |> discord_gleam.start() 22 | }) 23 | 24 | let assert Ok(_) = 25 | supervisor.new(supervisor.OneForOne) 26 | |> supervisor.add(bot) 27 | |> supervisor.start() 28 | 29 | process.sleep_forever() 30 | } 31 | 32 | fn simple_handler(bot, packet: event_handler.Packet) { 33 | case packet { 34 | event_handler.ReadyPacket(ready) -> { 35 | logging.log(logging.Info, "Logged in as " <> ready.d.user.username) 36 | 37 | Nil 38 | } 39 | event_handler.MessagePacket(message) -> { 40 | logging.log(logging.Info, "Message: " <> message.d.content) 41 | 42 | case string.starts_with(message.d.content, "!delete") { 43 | True -> { 44 | let args = string.split(message.d.content, " ") 45 | let args = list.drop(args, 1) 46 | 47 | let reason = string.join(args, " ") 48 | 49 | discord_gleam.delete_message( 50 | bot, 51 | message.d.channel_id, 52 | message.d.id, 53 | reason, 54 | ) 55 | 56 | Nil 57 | } 58 | False -> Nil 59 | } 60 | } 61 | _ -> Nil 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/discord_gleam/internal/error.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic/decode 2 | import gleam/httpc 3 | import gleam/json 4 | import gleam/list 5 | import gleam/otp/actor 6 | import gleam/string 7 | 8 | pub type DiscordError { 9 | UnknownAccount 10 | EmptyOptionWhenRequired 11 | JsonDecodeError(json.DecodeError) 12 | InvalidDynamicList(List(decode.DecodeError)) 13 | InvalidFormat(decode.DecodeError) 14 | WebsocketError(Nil) 15 | /// When a request to the API fails 16 | HttpError(httpc.HttpError) 17 | /// When the API returns an error, but the request was successful 18 | GenericHttpError(status_code: Int, body: String) 19 | ActorError(actor.StartError) 20 | NilMapEntry(Nil) 21 | /// Used when a builder dosen't have all of the properties it requires 22 | BadBuilderProperties(String) 23 | Unauthorized(String) 24 | } 25 | 26 | pub fn json_decode_error_to_string(error: json.DecodeError) -> String { 27 | case error { 28 | json.UnexpectedEndOfInput -> "Unexpected end of input" 29 | 30 | json.UnexpectedByte(byte) -> { 31 | "Unexpected byte: " <> byte 32 | } 33 | 34 | json.UnexpectedSequence(sequence) -> { 35 | "Unexpected sequence: " <> sequence 36 | } 37 | 38 | json.UnableToDecode(errs) -> { 39 | "Unable to decode: " 40 | <> string.join(list.map(errs, decode_error_to_string), with: ", ") 41 | } 42 | } 43 | } 44 | 45 | pub fn dynamic_decode_error_to_string(error: decode.DecodeError) -> String { 46 | "Expected " 47 | <> error.expected 48 | <> ", but found " 49 | <> error.found 50 | <> " at " 51 | <> string.join(error.path, with: ".") 52 | } 53 | 54 | pub fn decode_error_to_string(error: decode.DecodeError) -> String { 55 | "Expected " 56 | <> error.expected 57 | <> ", but found " 58 | <> error.found 59 | <> " at " 60 | <> string.join(error.path, with: ".") 61 | } 62 | -------------------------------------------------------------------------------- /src/discord_gleam/http/request.gleam: -------------------------------------------------------------------------------- 1 | //// This module contains functions to create http requests to discord 2 | 3 | import gleam/http 4 | import gleam/http/request 5 | 6 | /// Create a base request to discord 7 | pub fn new(method: http.Method, path: String) -> request.Request(String) { 8 | request.new() 9 | |> request.set_method(method) 10 | |> request.set_host("discord.com") 11 | |> request.set_path("/api/v10" <> path) 12 | |> request.prepend_header("accept", "application/json") 13 | |> request.prepend_header( 14 | "User-Agent", 15 | "DiscordBot (https://github.com/cyteon/discord_gleam, 2.1.0)", 16 | ) 17 | } 18 | 19 | /// Create an unauthenticated request, with a body 20 | pub fn new_with_body( 21 | method: http.Method, 22 | path: String, 23 | data: String, 24 | ) -> request.Request(String) { 25 | new(method, path) 26 | |> request.set_body(data) 27 | |> request.prepend_header("Content-Type", "application/json") 28 | } 29 | 30 | /// Create an authenticated request 31 | pub fn new_auth( 32 | method: http.Method, 33 | path: String, 34 | token: String, 35 | ) -> request.Request(String) { 36 | new(method, path) 37 | |> request.prepend_header("Authorization", "Bot " <> token) 38 | } 39 | 40 | /// Create an authenticated request, with a body 41 | pub fn new_auth_with_body( 42 | method: http.Method, 43 | path: String, 44 | token: String, 45 | data: String, 46 | ) -> request.Request(String) { 47 | new(method, path) 48 | |> request.prepend_header("Authorization", "Bot " <> token) 49 | |> request.set_body(data) 50 | |> request.prepend_header("Content-Type", "application/json") 51 | } 52 | 53 | /// Create an authenticated request with a custom header 54 | pub fn new_auth_with_header( 55 | method: http.Method, 56 | path: String, 57 | token: String, 58 | header: #(String, String), 59 | ) -> request.Request(String) { 60 | new_auth(method, path, token) 61 | |> request.set_header(header.0, header.1) 62 | } 63 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/commands/request_guild_members.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/bot 3 | import gleam/erlang/process 4 | import gleam/json 5 | import gleam/list 6 | import gleam/option.{type Option} 7 | 8 | pub type RequestGuildMembersOption { 9 | Query(String, limit: Option(Int)) 10 | UserIds(List(Snowflake)) 11 | } 12 | 13 | pub type RequestGuildMembersData { 14 | RequestGuildMembersData( 15 | guild_id: Snowflake, 16 | option: RequestGuildMembersOption, 17 | presences: Option(Bool), 18 | nonce: Option(String), 19 | ) 20 | } 21 | 22 | pub fn request_guild_members( 23 | bot: bot.Bot, 24 | guild_id guild_id: Snowflake, 25 | option option: RequestGuildMembersOption, 26 | presences presences: Option(Bool), 27 | nonce nonce: Option(String), 28 | ) -> Nil { 29 | let data = RequestGuildMembersData(guild_id:, option:, presences:, nonce:) 30 | 31 | let packet = 32 | json.object([#("op", json.int(8)), #("d", data_to_json(data))]) 33 | |> json.to_string() 34 | 35 | process.send(bot.subject, bot.SendPacket(packet)) 36 | } 37 | 38 | fn data_to_json(data: RequestGuildMembersData) -> json.Json { 39 | let fields = [ 40 | #("guild_id", json.string(data.guild_id)), 41 | ] 42 | 43 | let fields = case data.presences { 44 | option.Some(presences) -> 45 | list.append(fields, [#("presences", json.bool(presences))]) 46 | option.None -> fields 47 | } 48 | 49 | let fields = case data.nonce { 50 | option.Some(nonce) -> list.append(fields, [#("nonce", json.string(nonce))]) 51 | option.None -> fields 52 | } 53 | 54 | case data.option { 55 | Query(query, limit) -> 56 | list.append(fields, [ 57 | #("query", json.string(query)), 58 | #("limit", json.int(option.unwrap(limit, 0))), 59 | ]) 60 | UserIds(user_ids) -> 61 | list.append(fields, [#("user_ids", json.array(user_ids, of: json.string))]) 62 | } 63 | |> json.object() 64 | } 65 | -------------------------------------------------------------------------------- /src/discord_gleam/types/presence.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/activity 3 | import gleam/dynamic/decode 4 | import gleam/option.{type Option, None, Some} 5 | 6 | pub type ClientStatus { 7 | ClientStatus( 8 | desktop: Option(String), 9 | mobile: Option(String), 10 | web: Option(String), 11 | ) 12 | } 13 | 14 | // NOTE: `PRESENCE_UPDATE` packet includes only user id? 15 | 16 | pub type PresenceUser { 17 | PresenceUser(id: Snowflake) 18 | } 19 | 20 | pub type Presence { 21 | Presence( 22 | user: PresenceUser, 23 | // NOTE: `guild_id` does not exist? 24 | // https://discord.com/developers/docs/events/gateway-events#presence have 25 | // this field as required, but it is not present in the packet. 26 | // guild_id: Snowflake, 27 | status: String, 28 | activities: List(activity.Activity), 29 | client_status: ClientStatus, 30 | ) 31 | } 32 | 33 | pub fn from_json_decoder() -> decode.Decoder(Presence) { 34 | use user <- decode.field("user", presence_user_decoder()) 35 | use status <- decode.field("status", decode.string) 36 | use activities <- decode.field( 37 | "activities", 38 | decode.list(activity.from_json_decoder()), 39 | ) 40 | use client_status <- decode.field("client_status", client_status_decoder()) 41 | 42 | decode.success(Presence(user:, status:, activities:, client_status:)) 43 | } 44 | 45 | pub fn presence_user_decoder() -> decode.Decoder(PresenceUser) { 46 | use id <- decode.field("id", snowflake.decoder()) 47 | decode.success(PresenceUser(id:)) 48 | } 49 | 50 | pub fn client_status_decoder() { 51 | use desktop <- decode.optional_field( 52 | "desktop", 53 | None, 54 | decode.string |> decode.map(Some), 55 | ) 56 | use mobile <- decode.optional_field( 57 | "mobile", 58 | None, 59 | decode.string |> decode.map(Some), 60 | ) 61 | use web <- decode.optional_field( 62 | "web", 63 | None, 64 | decode.string |> decode.map(Some), 65 | ) 66 | 67 | decode.success(ClientStatus(desktop:, mobile:, web:)) 68 | } 69 | -------------------------------------------------------------------------------- /src/discord_gleam/types/role.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/internal/error 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | import gleam/option 6 | import gleam/result 7 | 8 | /// See https://discord.com/developers/docs/topics/permissions#role-object \ 9 | /// This is a simplified version of the channel object. 10 | pub type Role { 11 | Role( 12 | id: Snowflake, 13 | name: String, 14 | color: option.Option(Int), 15 | hoist: Bool, 16 | icon: option.Option(String), 17 | unicode_emoji: option.Option(String), 18 | position: Int, 19 | permissions: String, 20 | managed: Bool, 21 | mentionable: Bool, 22 | flags: Int, 23 | ) 24 | } 25 | 26 | /// Convert a JSON string to a role object 27 | pub fn string_to_data(encoded: String) -> Result(Role, error.DiscordError) { 28 | json.parse(from: encoded, using: from_json_decoder()) 29 | |> result.map_error(error.JsonDecodeError) 30 | } 31 | 32 | pub fn from_json_decoder() -> decode.Decoder(Role) { 33 | use id <- decode.field("id", snowflake.decoder()) 34 | use name <- decode.field("name", decode.string) 35 | use color <- decode.optional_field( 36 | "color", 37 | option.None, 38 | decode.optional(decode.int), 39 | ) 40 | use hoist <- decode.field("hoist", decode.bool) 41 | use icon <- decode.optional_field( 42 | "icon", 43 | option.None, 44 | decode.optional(decode.string), 45 | ) 46 | use unicode_emoji <- decode.optional_field( 47 | "unicode_emoji", 48 | option.None, 49 | decode.optional(decode.string), 50 | ) 51 | use position <- decode.field("position", decode.int) 52 | use permissions <- decode.field("permissions", decode.string) 53 | use managed <- decode.field("managed", decode.bool) 54 | use mentionable <- decode.field("mentionable", decode.bool) 55 | use flags <- decode.field("flags", decode.int) 56 | 57 | decode.success(Role( 58 | id:, 59 | name:, 60 | color:, 61 | hoist:, 62 | icon:, 63 | unicode_emoji:, 64 | position:, 65 | permissions:, 66 | managed:, 67 | mentionable:, 68 | flags:, 69 | )) 70 | } 71 | -------------------------------------------------------------------------------- /src/discord_gleam/types/user.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/internal/error 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | import gleam/option.{type Option} 6 | import gleam/result 7 | import gleam/string 8 | 9 | /// User object containing PartialUser and FullUser 10 | /// FullUser is currently not implemented 11 | pub type User { 12 | PartialUser( 13 | id: Snowflake, 14 | username: String, 15 | discriminator: String, 16 | avatar: Option(String), 17 | ) 18 | FullUser( 19 | id: Snowflake, 20 | username: String, 21 | discriminator: String, 22 | avatar: Option(String), 23 | bot: Bool, 24 | system: Bool, 25 | mfa_enabled: Bool, 26 | banner: Option(String), 27 | accent_color: Option(Int), 28 | verified: Bool, 29 | email: Option(String), 30 | flags: Int, 31 | premium_type: Int, 32 | public_flags: Int, 33 | ) 34 | } 35 | 36 | pub type AvatarDecoration { 37 | AvatarDecoration(asset: String, sku_id: Snowflake) 38 | } 39 | 40 | pub fn avatar_decoration_decoder() -> decode.Decoder(AvatarDecoration) { 41 | use asset <- decode.field("asset", decode.string) 42 | use sku_id <- decode.field("sku_id", snowflake.decoder()) 43 | decode.success(AvatarDecoration(asset:, sku_id:)) 44 | } 45 | 46 | /// Decode a string to a PartialUser 47 | pub fn string_to_data(encoded: String) -> Result(User, error.DiscordError) { 48 | case string.contains(encoded, "401: Unauthorized") { 49 | True -> { 50 | Error(error.Unauthorized("Error, 401, Unauthorized :c, is token correct?")) 51 | } 52 | False -> { 53 | json.parse(from: encoded, using: from_json_decoder()) 54 | |> result.map_error(error.JsonDecodeError) 55 | } 56 | } 57 | } 58 | 59 | pub fn from_json_decoder() -> decode.Decoder(User) { 60 | use id <- decode.field("id", snowflake.decoder()) 61 | use username <- decode.field("username", decode.string) 62 | use discriminator <- decode.field("discriminator", decode.string) 63 | use avatar <- decode.field("avatar", decode.optional(decode.string)) 64 | 65 | decode.success(PartialUser(id:, username:, discriminator:, avatar:)) 66 | } 67 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/guild_members_chunk.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/guild_member 3 | import discord_gleam/types/presence 4 | import gleam/dynamic/decode 5 | import gleam/json 6 | import gleam/option.{type Option, None} 7 | 8 | pub type GuildMembersChunkData { 9 | GuildMembersChunkData( 10 | guild_id: Snowflake, 11 | members: List(guild_member.GuildMember), 12 | chunk_index: Int, 13 | chunk_count: Int, 14 | not_found: Option(List(Snowflake)), 15 | presences: Option(List(presence.Presence)), 16 | nonce: Option(String), 17 | ) 18 | } 19 | 20 | pub type GuildMembersChunkPacket { 21 | GuildMembersChunkPacket(t: String, s: Int, op: Int, d: GuildMembersChunkData) 22 | } 23 | 24 | pub fn string_to_data( 25 | encoded: String, 26 | ) -> Result(GuildMembersChunkPacket, json.DecodeError) { 27 | let decoder = { 28 | use t <- decode.field("t", decode.string) 29 | use s <- decode.field("s", decode.int) 30 | use op <- decode.field("op", decode.int) 31 | use d <- decode.field("d", { 32 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 33 | use members <- decode.field( 34 | "members", 35 | decode.list(of: guild_member.from_json_decoder()), 36 | ) 37 | use chunk_index <- decode.field("chunk_index", decode.int) 38 | use chunk_count <- decode.field("chunk_count", decode.int) 39 | use not_found <- decode.optional_field( 40 | "not_found", 41 | None, 42 | decode.optional(decode.list(of: snowflake.decoder())), 43 | ) 44 | use presences <- decode.optional_field( 45 | "presences", 46 | None, 47 | decode.optional(decode.list(of: presence.from_json_decoder())), 48 | ) 49 | use nonce <- decode.optional_field( 50 | "nonce", 51 | None, 52 | decode.optional(decode.string), 53 | ) 54 | 55 | decode.success(GuildMembersChunkData( 56 | guild_id:, 57 | members:, 58 | chunk_index:, 59 | chunk_count:, 60 | not_found:, 61 | presences:, 62 | nonce:, 63 | )) 64 | }) 65 | 66 | decode.success(GuildMembersChunkPacket(t:, s:, op:, d:)) 67 | } 68 | 69 | json.parse(from: encoded, using: decoder) 70 | } 71 | -------------------------------------------------------------------------------- /src/discord_gleam/types/channel.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/internal/error 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | import gleam/option 6 | import gleam/result 7 | 8 | /// See https://discord.com/developers/docs/resources/channel#channel-object \ 9 | /// This is a simplified version of the channel object. 10 | pub type Channel { 11 | Channel( 12 | id: Snowflake, 13 | type_: Int, 14 | position: option.Option(Int), 15 | guild_id: option.Option(Snowflake), 16 | parent_id: option.Option(Snowflake), 17 | name: option.Option(String), 18 | topic: option.Option(String), 19 | nsfw: option.Option(Bool), 20 | last_message_id: option.Option(Snowflake), 21 | ) 22 | } 23 | 24 | /// Convert a JSON string to a channel object 25 | pub fn string_to_data(encoded: String) -> Result(Channel, error.DiscordError) { 26 | json.parse(from: encoded, using: from_json_decoder()) 27 | |> result.map_error(error.JsonDecodeError) 28 | } 29 | 30 | pub fn from_json_decoder() -> decode.Decoder(Channel) { 31 | use id <- decode.field("id", snowflake.decoder()) 32 | use type_ <- decode.field("type", decode.int) 33 | use position <- decode.optional_field( 34 | "position", 35 | option.None, 36 | decode.optional(decode.int), 37 | ) 38 | use guild_id <- decode.optional_field( 39 | "guild_id", 40 | option.None, 41 | decode.optional(snowflake.decoder()), 42 | ) 43 | use parent_id <- decode.optional_field( 44 | "parent_id", 45 | option.None, 46 | decode.optional(snowflake.decoder()), 47 | ) 48 | use name <- decode.optional_field( 49 | "name", 50 | option.None, 51 | decode.optional(decode.string), 52 | ) 53 | use topic <- decode.optional_field( 54 | "topic", 55 | option.None, 56 | decode.optional(decode.string), 57 | ) 58 | use nsfw <- decode.optional_field( 59 | "nsfw", 60 | option.None, 61 | decode.optional(decode.bool), 62 | ) 63 | use last_message_id <- decode.optional_field( 64 | "last_message_id", 65 | option.None, 66 | decode.optional(snowflake.decoder()), 67 | ) 68 | 69 | decode.success(Channel( 70 | id:, 71 | type_:, 72 | position:, 73 | guild_id:, 74 | parent_id:, 75 | name:, 76 | topic:, 77 | nsfw:, 78 | last_message_id:, 79 | )) 80 | } 81 | -------------------------------------------------------------------------------- /src/discord_gleam/types/guild_member.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/user 3 | import gleam/dynamic/decode 4 | import gleam/option.{type Option, None} 5 | 6 | pub type GuildMember { 7 | GuildMember( 8 | user: user.User, 9 | nick: Option(String), 10 | avatar: Option(String), 11 | banner: Option(String), 12 | roles: List(Snowflake), 13 | // ISO8601 timestamp? 14 | joined_at: String, 15 | premium_since: Option(String), 16 | deaf: Bool, 17 | mute: Bool, 18 | flags: Int, 19 | pending: Option(Bool), 20 | permissions: Option(String), 21 | communication_disabled_until: Option(String), 22 | avatar_decoration: Option(user.AvatarDecoration), 23 | ) 24 | } 25 | 26 | pub fn from_json_decoder() -> decode.Decoder(GuildMember) { 27 | use user <- decode.field("user", user.from_json_decoder()) 28 | use nick <- decode.optional_field( 29 | "nick", 30 | None, 31 | decode.optional(decode.string), 32 | ) 33 | use avatar <- decode.optional_field( 34 | "avatar", 35 | None, 36 | decode.optional(decode.string), 37 | ) 38 | use banner <- decode.optional_field( 39 | "banner", 40 | None, 41 | decode.optional(decode.string), 42 | ) 43 | use roles <- decode.field("roles", decode.list(of: snowflake.decoder())) 44 | use joined_at <- decode.field("joined_at", decode.string) 45 | use premium_since <- decode.optional_field( 46 | "premium_since", 47 | None, 48 | decode.optional(decode.string), 49 | ) 50 | use deaf <- decode.field("deaf", decode.bool) 51 | use mute <- decode.field("mute", decode.bool) 52 | use flags <- decode.field("flags", decode.int) 53 | use pending <- decode.optional_field( 54 | "pending", 55 | None, 56 | decode.optional(decode.bool), 57 | ) 58 | use permissions <- decode.optional_field( 59 | "permissions", 60 | None, 61 | decode.optional(decode.string), 62 | ) 63 | use communication_disabled_until <- decode.optional_field( 64 | "communication_disabled_until", 65 | None, 66 | decode.optional(decode.string), 67 | ) 68 | use avatar_decoration <- decode.optional_field( 69 | "avatar_decoration", 70 | None, 71 | decode.optional(user.avatar_decoration_decoder()), 72 | ) 73 | 74 | decode.success(GuildMember( 75 | user:, 76 | nick:, 77 | avatar:, 78 | banner:, 79 | roles:, 80 | joined_at:, 81 | premium_since:, 82 | deaf:, 83 | mute:, 84 | flags:, 85 | pending:, 86 | permissions:, 87 | communication_disabled_until:, 88 | avatar_decoration:, 89 | )) 90 | } 91 | -------------------------------------------------------------------------------- /src/discord_gleam/types/slash_command.gleam: -------------------------------------------------------------------------------- 1 | import gleam/json 2 | import gleam/list 3 | 4 | /// An simplified command option type \ 5 | /// See https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure 6 | pub type CommandOption { 7 | CommandOption( 8 | name: String, 9 | description: String, 10 | type_: CommandOptionType, 11 | required: Bool, 12 | ) 13 | } 14 | 15 | pub type CommandOptionType { 16 | SubCommandOption 17 | SubCommandGroupOption 18 | StringOption 19 | IntOption 20 | BoolOption 21 | UserOption 22 | ChannelOption 23 | RoleOption 24 | MentionableOption 25 | FloatOption 26 | AttachmentOption 27 | } 28 | 29 | pub type SlashCommand { 30 | SlashCommand(name: String, description: String, options: List(CommandOption)) 31 | } 32 | 33 | pub fn type_to_int(type_: CommandOptionType) -> Int { 34 | case type_ { 35 | SubCommandOption -> 1 36 | SubCommandGroupOption -> 2 37 | StringOption -> 3 38 | IntOption -> 4 39 | BoolOption -> 5 40 | UserOption -> 6 41 | ChannelOption -> 7 42 | RoleOption -> 8 43 | MentionableOption -> 9 44 | FloatOption -> 10 45 | AttachmentOption -> 11 46 | } 47 | } 48 | 49 | pub fn command_to_string(raw: SlashCommand) -> String { 50 | let options = list.map(raw.options, options_to_string) 51 | 52 | json.object([ 53 | #("name", json.string(raw.name)), 54 | #("type", json.int(1)), 55 | #("description", json.string(raw.description)), 56 | #("options", json.array(options, of: fn(x) { x })), 57 | ]) 58 | |> json.to_string 59 | } 60 | 61 | pub fn options_to_string(option: CommandOption) -> json.Json { 62 | json.object([ 63 | #("name", json.string(option.name)), 64 | #("description", json.string(option.description)), 65 | #("type", json.int(type_to_int(option.type_))), 66 | #("required", json.bool(option.required)), 67 | ]) 68 | } 69 | 70 | type BasicResponseData { 71 | BasicResponseData(content: String) 72 | } 73 | 74 | type BasicResponse { 75 | BasicResponse(type_: Int, data: BasicResponseData) 76 | } 77 | 78 | pub fn make_basic_text_reply(message: String, ephemeral: Bool) -> String { 79 | let data = BasicResponseData(content: message) 80 | let response = BasicResponse(type_: 4, data: data) 81 | 82 | let callback_data = case ephemeral { 83 | True -> [#("content", json.string(data.content)), #("flags", json.int(64))] 84 | False -> [#("content", json.string(data.content))] 85 | } 86 | 87 | json.object([ 88 | #("type", json.int(response.type_)), 89 | #("data", json.object(callback_data)), 90 | ]) 91 | |> json.to_string 92 | } 93 | -------------------------------------------------------------------------------- /examples/kick.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam 2 | import discord_gleam/discord/intents 3 | import discord_gleam/event_handler 4 | import discord_gleam/types/message 5 | import gleam/erlang/process 6 | import gleam/otp/static_supervisor as supervisor 7 | import gleam/otp/supervision 8 | import gleam/list 9 | import gleam/option.{None, Some} 10 | import gleam/string 11 | import logging 12 | 13 | pub fn main() { 14 | logging.configure() 15 | logging.set_level(logging.Info) 16 | 17 | let bot = discord_gleam.bot("token", "client id", intents.default()) 18 | 19 | let bot = 20 | supervision.worker(fn() { 21 | discord_gleam.simple(bot, [simple_handler]) 22 | |> discord_gleam.start() 23 | }) 24 | 25 | let assert Ok(_) = 26 | supervisor.new(supervisor.OneForOne) 27 | |> supervisor.add(bot) 28 | |> supervisor.start() 29 | 30 | process.sleep_forever() 31 | } 32 | 33 | fn simple_handler(bot, packet: event_handler.Packet) { 34 | case packet { 35 | event_handler.ReadyPacket(ready) -> { 36 | logging.log(logging.Info, "Logged in as " <> ready.d.user.username) 37 | 38 | Nil 39 | } 40 | event_handler.MessagePacket(message) -> { 41 | logging.log(logging.Info, "Message: " <> message.d.content) 42 | 43 | case string.starts_with(message.d.content, "!kick "), message.d.guild_id { 44 | True, Some(guild_id) -> { 45 | let args = string.split(message.d.content, " ") 46 | let args = list.drop(args, 1) 47 | 48 | let user = case list.first(args) { 49 | Ok(x) -> x 50 | Error(_) -> "" 51 | } 52 | 53 | let args = list.drop(args, 1) 54 | 55 | let user = string.replace(user, "<@", "") 56 | let user = string.replace(user, ">", "") 57 | 58 | let reason = string.join(args, " ") 59 | 60 | case message.d.guild_id { 61 | Some(guild_id) -> { 62 | let result = 63 | discord_gleam.kick_member(bot, guild_id, user, reason) 64 | 65 | case result { 66 | Ok(_) -> { 67 | discord_gleam.send_message( 68 | bot, 69 | message.d.channel_id, 70 | "Kicked user!", 71 | [], 72 | ) 73 | 74 | Nil 75 | } 76 | 77 | Error(_) -> { 78 | discord_gleam.send_message( 79 | bot, 80 | message.d.channel_id, 81 | "Failed to kick user", 82 | [], 83 | ) 84 | 85 | Nil 86 | } 87 | } 88 | } 89 | None -> { 90 | discord_gleam.send_message( 91 | bot, 92 | message.d.channel_id, 93 | "This command can only be used in a guild.", 94 | [], 95 | ) 96 | 97 | Nil 98 | } 99 | } 100 | } 101 | _, _ -> Nil 102 | } 103 | } 104 | _ -> Nil 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /examples/ban.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam 2 | import discord_gleam/discord/intents 3 | import discord_gleam/event_handler 4 | import discord_gleam/types/message 5 | import gleam/erlang/process 6 | import gleam/otp/static_supervisor as supervisor 7 | import gleam/otp/supervision 8 | import gleam/io 9 | import gleam/list 10 | import gleam/option 11 | import gleam/string 12 | import logging 13 | 14 | pub fn main() { 15 | logging.configure() 16 | logging.set_level(logging.Info) 17 | 18 | let bot = discord_gleam.bot("TOKEN", "CLIENT ID", intents.default()) 19 | 20 | let bot = 21 | supervision.worker(fn() { 22 | discord_gleam.simple(bot, [simple_handler]) 23 | |> discord_gleam.start() 24 | }) 25 | 26 | let assert Ok(_) = 27 | supervisor.new(supervisor.OneForOne) 28 | |> supervisor.add(bot) 29 | |> supervisor.start() 30 | 31 | process.sleep_forever() 32 | } 33 | 34 | fn simple_handler(bot, packet: event_handler.Packet) { 35 | case packet { 36 | event_handler.ReadyPacket(ready) -> { 37 | logging.log(logging.Info, "Logged in as " <> ready.d.user.username) 38 | 39 | Nil 40 | } 41 | 42 | event_handler.MessagePacket(message) -> { 43 | logging.log(logging.Info, "Message: " <> message.d.content) 44 | 45 | case string.starts_with(message.d.content, "!ban ") { 46 | True -> { 47 | let args = string.split(message.d.content, " ") 48 | 49 | let args = list.drop(args, 1) 50 | 51 | let user = case list.first(args) { 52 | Ok(x) -> x 53 | Error(_) -> "" 54 | } 55 | 56 | let args = list.drop(args, 1) 57 | 58 | let user = string.replace(user, "<@", "") 59 | let user = string.replace(user, ">", "") 60 | 61 | let reason = string.join(args, " ") 62 | 63 | case message.d.guild_id { 64 | option.Some(id) -> { 65 | let resp = discord_gleam.ban_member(bot, id, user, reason) 66 | 67 | case resp { 68 | Ok(_) -> { 69 | discord_gleam.send_message( 70 | bot, 71 | message.d.channel_id, 72 | "Banned user!", 73 | [], 74 | ) 75 | 76 | Nil 77 | } 78 | 79 | Error(err) -> { 80 | discord_gleam.send_message( 81 | bot, 82 | message.d.channel_id, 83 | "Failed to ban user!", 84 | [], 85 | ) 86 | 87 | echo err 88 | 89 | Nil 90 | } 91 | } 92 | } 93 | 94 | option.None -> { 95 | discord_gleam.send_message( 96 | bot, 97 | message.d.channel_id, 98 | "This command can only be used in a guild!", 99 | [], 100 | ) 101 | 102 | Nil 103 | } 104 | } 105 | 106 | Nil 107 | } 108 | 109 | False -> Nil 110 | } 111 | } 112 | _ -> Nil 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /examples/slash_commands.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam 2 | import discord_gleam/discord/intents 3 | import discord_gleam/event_handler 4 | import discord_gleam/types/slash_command 5 | import discord_gleam/ws/packets/interaction_create 6 | import gleam/erlang/process 7 | import gleam/otp/static_supervisor as supervisor 8 | import gleam/otp/supervision 9 | import gleam/list 10 | import gleam/option 11 | import logging 12 | 13 | pub fn main() { 14 | logging.configure() 15 | logging.set_level(logging.Info) 16 | 17 | let bot = discord_gleam.bot("TOKEN", "CLIENT_ID", intents.default()) 18 | 19 | let test_cmd = 20 | slash_command.SlashCommand( 21 | name: "ping", 22 | description: "returns pong", 23 | options: [ 24 | slash_command.CommandOption( 25 | name: "test", 26 | description: "string yummy", 27 | type_: slash_command.StringOption, 28 | required: False, 29 | ), 30 | ], 31 | ) 32 | 33 | let test_cmd2 = 34 | slash_command.SlashCommand( 35 | name: "pong", 36 | description: "returns ping", 37 | options: [], 38 | ) 39 | 40 | discord_gleam.register_global_commands(bot, [test_cmd]) 41 | 42 | discord_gleam.register_guild_commands(bot, "GUILD_ID", [test_cmd2]) 43 | 44 | let bot = 45 | supervision.worker(fn() { 46 | discord_gleam.simple(bot, [simple_handler]) 47 | |> discord_gleam.start() 48 | }) 49 | 50 | let assert Ok(_) = 51 | supervisor.new(supervisor.OneForOne) 52 | |> supervisor.add(bot) 53 | |> supervisor.start() 54 | 55 | process.sleep_forever() 56 | } 57 | 58 | fn simple_handler(bot, packet: event_handler.Packet) { 59 | case packet { 60 | event_handler.ReadyPacket(ready) -> { 61 | logging.log(logging.Info, "Logged in as " <> ready.d.user.username) 62 | 63 | Nil 64 | } 65 | 66 | event_handler.InteractionCreate(interaction) -> { 67 | logging.log(logging.Info, "Interaction: " <> interaction.d.data.name) 68 | 69 | case interaction.d.data.name { 70 | "ping" -> { 71 | case interaction.d.data.options { 72 | option.Some(options) -> { 73 | case list.first(options) { 74 | Ok(option) -> { 75 | let value = case option.value { 76 | interaction_create.StringValue(value) -> value 77 | _ -> "unexpected value type" 78 | } 79 | 80 | discord_gleam.interaction_reply_message( 81 | interaction, 82 | "pong: " <> value, 83 | False, 84 | ) 85 | } 86 | 87 | Error(_) -> 88 | discord_gleam.interaction_reply_message( 89 | interaction, 90 | "pong", 91 | False, 92 | ) 93 | } 94 | } 95 | 96 | option.None -> 97 | discord_gleam.interaction_reply_message( 98 | interaction, 99 | "pong", 100 | False, 101 | ) 102 | } 103 | 104 | Nil 105 | } 106 | 107 | "pong" -> { 108 | discord_gleam.interaction_reply_message(interaction, "ping", False) 109 | 110 | Nil 111 | } 112 | _ -> Nil 113 | } 114 | } 115 | _ -> Nil 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/packets/interaction_create.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import discord_gleam/types/user 3 | import gleam/dynamic/decode 4 | import gleam/json 5 | import gleam/option.{type Option} 6 | 7 | pub type InteractionCreateMember { 8 | InteractionCreateMember(user: user.User) 9 | } 10 | 11 | pub type InteractionCommand { 12 | InteractionCommand( 13 | type_: Int, 14 | name: String, 15 | id: Snowflake, 16 | options: Option(List(InteractionOption)), 17 | ) 18 | } 19 | 20 | pub type InteractionOption { 21 | InteractionOption( 22 | name: String, 23 | type_: Int, 24 | value: OptionValue, 25 | options: Option(List(InteractionOption)), 26 | ) 27 | } 28 | 29 | pub type InteractionCreateData { 30 | InteractionCreateData( 31 | token: String, 32 | member: InteractionCreateMember, 33 | id: Snowflake, 34 | guild_id: Snowflake, 35 | data: InteractionCommand, 36 | channel_id: Snowflake, 37 | ) 38 | } 39 | 40 | pub type InteractionCreatePacket { 41 | InteractionCreatePacket(t: String, s: Int, op: Int, d: InteractionCreateData) 42 | } 43 | 44 | pub type OptionValue { 45 | StringValue(String) 46 | IntValue(Int) 47 | BoolValue(Bool) 48 | FloatValue(Float) 49 | } 50 | 51 | fn options_decoder() -> decode.Decoder(InteractionOption) { 52 | use name <- decode.field("name", decode.string) 53 | use type_ <- decode.field("type", decode.int) 54 | use value <- decode.field( 55 | "value", 56 | decode.one_of(decode.string |> decode.map(StringValue), or: [ 57 | decode.int |> decode.map(IntValue), 58 | decode.bool |> decode.map(BoolValue), 59 | decode.float |> decode.map(FloatValue), 60 | ]), 61 | ) 62 | 63 | use options <- decode.optional_field( 64 | "options", 65 | option.None, 66 | decode.optional(decode.list(options_decoder())), 67 | ) 68 | 69 | decode.success(InteractionOption(name:, type_:, value:, options:)) 70 | } 71 | 72 | pub fn string_to_data( 73 | encoded: String, 74 | ) -> Result(InteractionCreatePacket, json.DecodeError) { 75 | let decoder = { 76 | use t <- decode.field("t", decode.string) 77 | use s <- decode.field("s", decode.int) 78 | use op <- decode.field("op", decode.int) 79 | use d <- decode.field("d", { 80 | use token <- decode.field("token", decode.string) 81 | use member <- decode.field("member", { 82 | use user <- decode.field("user", user.from_json_decoder()) 83 | decode.success(InteractionCreateMember(user:)) 84 | }) 85 | 86 | use id <- decode.field("id", snowflake.decoder()) 87 | use guild_id <- decode.field("guild_id", snowflake.decoder()) 88 | use data <- decode.field("data", { 89 | use type_ <- decode.field("type", decode.int) 90 | use name <- decode.field("name", decode.string) 91 | use id <- decode.field("id", snowflake.decoder()) 92 | 93 | use options <- decode.optional_field( 94 | "options", 95 | option.None, 96 | decode.optional(decode.list(options_decoder())), 97 | ) 98 | 99 | decode.success(InteractionCommand(type_:, name:, id:, options:)) 100 | }) 101 | 102 | use channel_id <- decode.field("channel_id", snowflake.decoder()) 103 | decode.success(InteractionCreateData( 104 | token:, 105 | member:, 106 | id:, 107 | guild_id:, 108 | data:, 109 | channel_id:, 110 | )) 111 | }) 112 | decode.success(InteractionCreatePacket(t:, s:, op:, d:)) 113 | } 114 | 115 | json.parse(from: encoded, using: decoder) 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord_gleam 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/discord_gleam)](https://hex.pm/packages/discord_gleam) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/discord_gleam/) 5 | 6 | ```sh 7 | gleam add discord_gleam 8 | ``` 9 | 10 | ```gleam 11 | import discord_gleam 12 | import discord_gleam/discord/intents 13 | import discord_gleam/event_handler 14 | import discord_gleam/types/message 15 | import gleam/erlang/process 16 | import gleam/list 17 | import gleam/otp/static_supervisor as supervisor 18 | import gleam/otp/supervision 19 | import gleam/string 20 | import logging 21 | 22 | pub fn main() { 23 | logging.configure() 24 | logging.set_level(logging.Info) 25 | 26 | let bot = discord_gleam.bot("token", "client id", intents.default()) 27 | 28 | let bot = 29 | supervision.worker(fn() { 30 | discord_gleam.simple(bot, [simple_handler]) 31 | |> discord_gleam.start() 32 | }) 33 | 34 | let assert Ok(_) = 35 | supervisor.new(supervisor.OneForOne) 36 | |> supervisor.add(bot) 37 | |> supervisor.start() 38 | 39 | process.sleep_forever() 40 | } 41 | 42 | fn simple_handler(bot, packet: event_handler.Packet) { 43 | case packet { 44 | event_handler.MessagePacket(message) -> { 45 | logging.log(logging.Info, "Got message: " <> message.d.content) 46 | 47 | case message.d.content { 48 | "!ping" -> { 49 | discord_gleam.send_message(bot, message.d.channel_id, "Pong!", []) 50 | 51 | Nil 52 | } 53 | 54 | _ -> Nil 55 | } 56 | } 57 | 58 | _ -> Nil 59 | } 60 | } 61 | ``` 62 | 63 | Further documentation can be found at . 64 | 65 | ## Development 66 | 67 | ```sh 68 | gleam test # Run the tests 69 | ``` 70 | 71 | ## Features: 72 | 73 | | Feature | Status | 74 | | --------------------- | ------ | 75 | | Basic events | ✅ | 76 | | Sending messages | ✅ | 77 | | Ban/kick | ✅ | 78 | | Deleting messages | ✅ | 79 | | Embeds | ✅ | 80 | | Basic Slash commands | ✅ | 81 | | Message Cache | ✅ | 82 | | Intents | ✅* | 83 | 84 | ✅ - Done | 🔨 - In Progress | 📆 - Planned | ❌ - Not Planned \ 85 | \* all intents are implemented, but not all are used yet 86 | 87 | ## Supported events: 88 | 89 | - [x] READY 90 | - [x] INTERACTION_CREATE 91 | 92 | Intent: guild_messages/direct_messages (optional: message_content) 93 | - [x] MESSAGE_CREATE 94 | - [x] MESSAGE_DELETE 95 | - [x] MESSAGE_UPDATE 96 | - [x] MESSAGE_DELETE_BULK 97 | 98 | Intent: guilds 99 | - [ ] GUILD_CREATE 100 | - [ ] GUILD_UPDATE 101 | - [ ] GUILD_DELETE 102 | - [x] CHANNEL_CREATE 103 | - [x] CHANNEL_UPDATE 104 | - [x] CHANNEL_DELETE 105 | - [ ] CHANNEL_PINS_UPDATE 106 | - [ ] THREAD_CREATE 107 | - [ ] THREAD_UPDATE 108 | - [ ] THREAD_DELETE 109 | - [ ] THREAD_LIST_SYNC 110 | - [ ] THREAD_MEMBER_UPDATE 111 | - [ ] THREAD_MEMBERS_UPDATE 112 | - [ ] STAGE_INSTANCE_CREATE 113 | - [ ] STAGE_INSTANCE_UPDATE 114 | - [ ] STAGE_INSTANCE_DELETE 115 | - [x] GUILD_ROLE_CREATE 116 | - [x] GUILD_ROLE_UPDATE 117 | - [x] GUILD_ROLE_DELETE 118 | 119 | Intent: guild_members 120 | - [x] GUILD_MEMBER_ADD 121 | - [x] GUILD_MEMBER_UPDATE 122 | - [x] GUILD_MEMBER_REMOVE 123 | - [x] GUILD_MEMBERS_CHUNK 124 | - [ ] THREAD_MEMBERS_UPDATE 125 | 126 | Intent: guild_moderation 127 | - [ ] GUILD_AUDIT_LOG_ENTRY_CREATE 128 | - [x] GUILD_BAN_ADD 129 | - [x] GUILD_BAN_REMOVE 130 | 131 | Intent: guild_expressions 132 | - [ ] GUILD_EMOJIS_UPDATE 133 | - [ ] GUILD_STICKERS_UPDATE 134 | - [ ] GUILD_SOUNDBOARD_SOUND_CREATE 135 | - [ ] GUILD_SOUNDBOARD_SOUND_UPDATE 136 | - [ ] GUILD_SOUNDBOARD_SOUND_DELETE 137 | - [ ] GUILD_SOUNDBOARD_SOUNDS_UPDATE 138 | 139 | Intent: guild_integrations 140 | - [ ] GUILD_INTEGRATIONS_UPDATE 141 | - [ ] INTEGRATION_CREATE 142 | - [ ] INTEGRATION_UPDATE 143 | - [ ] INTEGRATION_DELETE 144 | 145 | Intent: guild_webhooks 146 | - [ ] WEBHOOKS_UPDATE 147 | 148 | Intent: guild_invites 149 | - [ ] INVITE_CREATE 150 | - [ ] INVITE_DELETE 151 | 152 | Intent: guild_voice_states 153 | - [ ] VOICE_CHANNEL_EFFECT_SEND 154 | - [ ] VOICE_STATE_UPDATE 155 | 156 | Intent: guild_presences 157 | - [x] PRESENCE_UPDATE 158 | 159 | Intent: guild_message_reactions/direct_message_reactions 160 | - [ ] MESSAGE_REACTION_ADD 161 | - [ ] MESSAGE_REACTION_REMOVE 162 | - [ ] MESSAGE_REACTION_REMOVE_ALL 163 | - [ ] MESSAGE_REACTION_REMOVE_EMOJI 164 | 165 | Intent: guild_message_typing/direct_message_typing 166 | - [ ] TYPING_START 167 | 168 | Intent: guild_scheduled_events 169 | - [ ] GUILD_SCHEDULED_EVENT_CREATE 170 | - [ ] GUILD_SCHEDULED_EVENT_UPDATE 171 | - [ ] GUILD_SCHEDULED_EVENT_DELETE 172 | - [ ] GUILD_SCHEDULED_EVENT_USER_ADD 173 | - [ ] GUILD_SCHEDULED_EVENT_USER_REMOVE 174 | 175 | Intent: auto_moderation_configuration 176 | - [ ] AUTO_MODERATION_RULE_CREATE 177 | - [ ] AUTO_MODERATION_RULE_UPDATE 178 | - [ ] AUTO_MODERATION_RULE_DELETE 179 | 180 | Intent: auto_moderation_execution 181 | - [ ] AUTO_MODERATION_ACTION_EXECUTION 182 | 183 | Intent: guild_message_polls 184 | - [ ] MESSAGE_POLL_VOTE_ADD 185 | - [ ] MESSAGE_POLL_VOTE_REMOVE 186 | 187 | Intent: direct_message_polls 188 | - [ ] MESSAGE_POLL_VOTE_ADD 189 | - [ ] MESSAGE_POLL_VOTE_REMOVE 190 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "booklet", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "279247A5FD6388B34058A6109E99D7E7C7A4CA3EC8A13912536A05E98BC2D275" }, 6 | { name = "discord_gleam_stratus", version = "1.0.2", build_tools = ["gleam"], requirements = ["exception", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gramps", "logging"], otp_app = "discord_gleam_stratus", source = "hex", outer_checksum = "30CABB6B27B6A98B28B9871BDC53B58AD011AB74E2193D981901CB70A48D206F" }, 7 | { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 8 | { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 9 | { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 10 | { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 11 | { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 12 | { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 13 | { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 14 | { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" }, 15 | { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 16 | { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, 17 | { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, 18 | { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, 19 | { name = "glenvy", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib", "nibble", "simplifile"], otp_app = "glenvy", source = "hex", outer_checksum = "A954C46D079ABE2D0A31CF36F1EB99371BE38E5F85BF821A8C90B77022852CC6" }, 20 | { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, 21 | { name = "iv", version = "1.3.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "iv", source = "hex", outer_checksum = "1FE22E047705BE69EA366E3A2E73C2E1310CBCB27DDE767DE17AE3FA86499947" }, 22 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 23 | { name = "nibble", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "iv"], otp_app = "nibble", source = "hex", outer_checksum = "06397501730FF486AE6F99299982A33F5EA9F8945B5A25920C82C8F924CEA481" }, 24 | { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, 25 | { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 26 | ] 27 | 28 | [requirements] 29 | booklet = { version = ">= 1.0.2 and < 2.0.0" } 30 | discord_gleam_stratus = { version = ">= 1.0.2 and < 2.0.0" } 31 | gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } 32 | gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 33 | gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 34 | gleam_json = { version = ">= 3.0.1 and < 4.0.0" } 35 | gleam_otp = { version = ">= 1.0.0 and < 2.0.0" } 36 | gleam_stdlib = { version = ">= 0.60.0 and < 1.0.0" } 37 | gleeunit = { version = ">= 1.5.1 and < 2.0.0" } 38 | glenvy = { version = ">= 2.0.1" } 39 | logging = { version = ">= 1.3.0 and < 2.0.0" } 40 | repeatedly = { version = ">= 2.1.2 and < 3.0.0" } 41 | -------------------------------------------------------------------------------- /src/discord_gleam/discord/intents.gleam: -------------------------------------------------------------------------------- 1 | import gleam/int 2 | 3 | /// See https://discord.com/developers/docs/events/gateway#gateway-intents \ 4 | /// NOTE: While we have implemented all intents, we have not implemented all gateway events. 5 | pub type Intents { 6 | Intents( 7 | guilds: Bool, 8 | guild_members: Bool, 9 | guild_moderation: Bool, 10 | guild_expressions: Bool, 11 | guild_integrations: Bool, 12 | guild_webhooks: Bool, 13 | guild_invites: Bool, 14 | guild_voice_states: Bool, 15 | guild_presences: Bool, 16 | guild_messages: Bool, 17 | guild_message_reactions: Bool, 18 | guild_message_typing: Bool, 19 | direct_messages: Bool, 20 | direct_message_reactions: Bool, 21 | direct_message_typing: Bool, 22 | message_content: Bool, 23 | guild_scheduled_events: Bool, 24 | auto_moderation_configuration: Bool, 25 | auto_moderation_execution: Bool, 26 | guild_message_polls: Bool, 27 | direct_message_polls: Bool, 28 | ) 29 | } 30 | 31 | fn add_intent_bit(bitfield: Int, intent_enabled: Bool, bit_position: Int) -> Int { 32 | case intent_enabled { 33 | False -> bitfield 34 | True -> int.bitwise_or(bitfield, int.bitwise_shift_left(1, bit_position)) 35 | } 36 | } 37 | 38 | /// Calculate a bitfield from a set of intents. 39 | pub fn intents_to_bitfield(intents: Intents) -> Int { 40 | 0 41 | |> add_intent_bit(intents.guilds, 0) 42 | |> add_intent_bit(intents.guild_members, 1) 43 | |> add_intent_bit(intents.guild_moderation, 2) 44 | |> add_intent_bit(intents.guild_expressions, 3) 45 | |> add_intent_bit(intents.guild_integrations, 4) 46 | |> add_intent_bit(intents.guild_webhooks, 5) 47 | |> add_intent_bit(intents.guild_invites, 6) 48 | |> add_intent_bit(intents.guild_voice_states, 7) 49 | |> add_intent_bit(intents.guild_presences, 8) 50 | |> add_intent_bit(intents.guild_messages, 9) 51 | |> add_intent_bit(intents.guild_message_reactions, 10) 52 | |> add_intent_bit(intents.guild_message_typing, 11) 53 | |> add_intent_bit(intents.direct_messages, 12) 54 | |> add_intent_bit(intents.direct_message_reactions, 13) 55 | |> add_intent_bit(intents.direct_message_typing, 14) 56 | |> add_intent_bit(intents.message_content, 15) 57 | |> add_intent_bit(intents.guild_scheduled_events, 16) 58 | |> add_intent_bit(intents.auto_moderation_configuration, 20) 59 | |> add_intent_bit(intents.auto_moderation_execution, 21) 60 | |> add_intent_bit(intents.guild_message_polls, 24) 61 | |> add_intent_bit(intents.direct_message_polls, 25) 62 | } 63 | 64 | /// Enable a set of default intents, which are usually used by most bots. \ 65 | /// Does not include `message_content` intent, as its a privileged intent 66 | pub fn default() -> Intents { 67 | Intents( 68 | guilds: True, 69 | guild_members: False, 70 | guild_moderation: False, 71 | guild_expressions: False, 72 | guild_integrations: False, 73 | guild_webhooks: False, 74 | guild_invites: False, 75 | guild_voice_states: False, 76 | guild_presences: False, 77 | guild_messages: True, 78 | guild_message_reactions: True, 79 | guild_message_typing: False, 80 | direct_messages: True, 81 | direct_message_reactions: True, 82 | direct_message_typing: False, 83 | message_content: True, 84 | guild_scheduled_events: False, 85 | auto_moderation_configuration: False, 86 | auto_moderation_execution: False, 87 | guild_message_polls: False, 88 | direct_message_polls: False, 89 | ) 90 | } 91 | 92 | /// Enable a set of default intents, which are usually used by most bots. \ 93 | /// But also includes all intents relevant to messages 94 | pub fn default_with_message_intents() -> Intents { 95 | Intents( 96 | guilds: True, 97 | guild_members: False, 98 | guild_moderation: False, 99 | guild_expressions: False, 100 | guild_integrations: False, 101 | guild_webhooks: False, 102 | guild_invites: False, 103 | guild_voice_states: False, 104 | guild_presences: False, 105 | guild_messages: True, 106 | guild_message_reactions: True, 107 | guild_message_typing: True, 108 | direct_messages: True, 109 | direct_message_reactions: True, 110 | direct_message_typing: True, 111 | message_content: True, 112 | guild_scheduled_events: False, 113 | auto_moderation_configuration: False, 114 | auto_moderation_execution: False, 115 | guild_message_polls: True, 116 | direct_message_polls: True, 117 | ) 118 | } 119 | 120 | /// Enable all the intents, use this if you want to receive all supported events. 121 | pub fn all() -> Intents { 122 | Intents( 123 | guilds: True, 124 | guild_members: True, 125 | guild_moderation: True, 126 | guild_expressions: True, 127 | guild_integrations: True, 128 | guild_webhooks: True, 129 | guild_invites: True, 130 | guild_voice_states: True, 131 | guild_presences: True, 132 | guild_messages: True, 133 | guild_message_reactions: True, 134 | guild_message_typing: True, 135 | direct_messages: True, 136 | direct_message_reactions: True, 137 | direct_message_typing: True, 138 | message_content: True, 139 | guild_scheduled_events: True, 140 | auto_moderation_configuration: True, 141 | auto_moderation_execution: True, 142 | guild_message_polls: True, 143 | direct_message_polls: True, 144 | ) 145 | } 146 | 147 | /// Disable all the intents, use this if you want to receive no events other than `interaction_create or ready. \ 148 | /// Useful if you have a bot with slash commands only, that dosen't need to listen to events. 149 | pub fn none() -> Intents { 150 | Intents( 151 | guilds: False, 152 | guild_members: False, 153 | guild_moderation: False, 154 | guild_expressions: False, 155 | guild_integrations: False, 156 | guild_webhooks: False, 157 | guild_invites: False, 158 | guild_voice_states: False, 159 | guild_presences: False, 160 | guild_messages: False, 161 | guild_message_reactions: False, 162 | guild_message_typing: False, 163 | direct_messages: False, 164 | direct_message_reactions: False, 165 | direct_message_typing: False, 166 | message_content: False, 167 | guild_scheduled_events: False, 168 | auto_moderation_configuration: False, 169 | auto_moderation_execution: False, 170 | guild_message_polls: False, 171 | direct_message_polls: False, 172 | ) 173 | } 174 | -------------------------------------------------------------------------------- /src/discord_gleam/types/activity.gleam: -------------------------------------------------------------------------------- 1 | import discord_gleam/discord/snowflake.{type Snowflake} 2 | import gleam/dynamic/decode 3 | import gleam/option.{type Option, None, Some} 4 | import gleam/string 5 | 6 | pub type ActivityTimestamp { 7 | ActivityTimestamp(start: Option(Int), end: Option(Int)) 8 | } 9 | 10 | pub type ActivityEmoji { 11 | ActivityEmoji(name: String, id: Option(Snowflake), animated: Option(Bool)) 12 | } 13 | 14 | pub type ActivityParty { 15 | ActivityParty(id: Option(String), size: Option(#(Int, Int))) 16 | } 17 | 18 | pub type ActivityAssets { 19 | ActivityAssets( 20 | large_image: Option(String), 21 | large_text: Option(String), 22 | large_url: Option(String), 23 | small_image: Option(String), 24 | small_text: Option(String), 25 | small_url: Option(String), 26 | ) 27 | } 28 | 29 | pub type ActivitySecrets { 30 | ActivitySecrets( 31 | join: Option(String), 32 | spectate: Option(String), 33 | match: Option(String), 34 | ) 35 | } 36 | 37 | pub type ActivityButton { 38 | ActivityButton(label: String, url: String) 39 | } 40 | 41 | /// See https://discord.com/developers/docs/events/gateway-events#activity-object 42 | pub type Activity { 43 | Activity( 44 | name: String, 45 | type_: Int, 46 | url: Option(String), 47 | created_at: Int, 48 | timestamps: Option(ActivityTimestamp), 49 | application_id: Option(Snowflake), 50 | status_display_type: Option(Int), 51 | details: Option(String), 52 | details_url: Option(String), 53 | state: Option(String), 54 | state_url: Option(String), 55 | emoji: Option(ActivityEmoji), 56 | party: Option(ActivityParty), 57 | assets: Option(ActivityAssets), 58 | secrets: Option(ActivitySecrets), 59 | instance: Option(Bool), 60 | flags: Option(Int), 61 | buttons: Option(List(String)), 62 | ) 63 | } 64 | 65 | pub fn from_json_decoder() -> decode.Decoder(Activity) { 66 | use name <- decode.field("name", decode.string) 67 | use type_ <- decode.field("type", decode.int) 68 | use url <- decode.optional_field("url", None, decode.optional(decode.string)) 69 | use created_at <- decode.field("created_at", decode.int) 70 | use timestamps <- decode.optional_field( 71 | "timestamps", 72 | None, 73 | decode.optional(activity_timestamp_decoder()), 74 | ) 75 | use application_id <- decode.optional_field( 76 | "application_id", 77 | None, 78 | decode.optional(snowflake.decoder()), 79 | ) 80 | use status_display_type <- decode.optional_field( 81 | "status_display_type", 82 | None, 83 | decode.optional(decode.int), 84 | ) 85 | use details <- decode.optional_field( 86 | "details", 87 | None, 88 | decode.optional(decode.string), 89 | ) 90 | use details_url <- decode.optional_field( 91 | "details_url", 92 | None, 93 | decode.optional(decode.string), 94 | ) 95 | use state <- decode.optional_field( 96 | "state", 97 | None, 98 | decode.optional(decode.string), 99 | ) 100 | use state_url <- decode.optional_field( 101 | "state_url", 102 | None, 103 | decode.optional(decode.string), 104 | ) 105 | use emoji <- decode.optional_field( 106 | "emoji", 107 | None, 108 | decode.optional(activity_emoji_decoder()), 109 | ) 110 | use party <- decode.optional_field( 111 | "party", 112 | None, 113 | decode.optional(activity_party_decoder()), 114 | ) 115 | use assets <- decode.optional_field( 116 | "assets", 117 | None, 118 | decode.optional(activity_assets_decoder()), 119 | ) 120 | use secrets <- decode.optional_field( 121 | "secrets", 122 | None, 123 | decode.optional(activity_secrets_decoder()), 124 | ) 125 | use instance <- decode.optional_field( 126 | "instance", 127 | None, 128 | decode.optional(decode.bool), 129 | ) 130 | use flags <- decode.optional_field("flags", None, decode.optional(decode.int)) 131 | use buttons <- decode.optional_field( 132 | "buttons", 133 | None, 134 | decode.optional(decode.list(of: decode.string)), 135 | ) 136 | 137 | decode.success(Activity( 138 | name:, 139 | type_:, 140 | url:, 141 | created_at:, 142 | timestamps:, 143 | application_id:, 144 | status_display_type:, 145 | details:, 146 | details_url:, 147 | state:, 148 | state_url:, 149 | emoji:, 150 | party:, 151 | assets:, 152 | secrets:, 153 | instance:, 154 | flags:, 155 | buttons:, 156 | )) 157 | } 158 | 159 | fn activity_timestamp_decoder() { 160 | use start <- decode.optional_field("start", None, decode.optional(decode.int)) 161 | use end <- decode.optional_field("end", None, decode.optional(decode.int)) 162 | decode.success(ActivityTimestamp(start:, end:)) 163 | } 164 | 165 | fn activity_emoji_decoder() { 166 | use name <- decode.field("name", decode.string) 167 | use id <- decode.optional_field( 168 | "id", 169 | None, 170 | decode.optional(snowflake.decoder()), 171 | ) 172 | use animated <- decode.optional_field( 173 | "animated", 174 | None, 175 | decode.optional(decode.bool), 176 | ) 177 | decode.success(ActivityEmoji(name:, id:, animated:)) 178 | } 179 | 180 | fn activity_party_decoder() { 181 | use id <- decode.optional_field("id", None, decode.optional(decode.string)) 182 | use size <- decode.optional_field( 183 | "size", 184 | None, 185 | decode.optional(decode.list(of: decode.int)), 186 | ) 187 | 188 | case size { 189 | Some([current_size, max_size]) -> 190 | decode.success(ActivityParty(id:, size: Some(#(current_size, max_size)))) 191 | Some(_) -> 192 | decode.failure( 193 | ActivityParty(None, None), 194 | "Expected list of two ints, but found " <> string.inspect(size), 195 | ) 196 | None -> decode.success(ActivityParty(id:, size: None)) 197 | } 198 | } 199 | 200 | fn activity_assets_decoder() { 201 | use large_image <- decode.optional_field( 202 | "large_image", 203 | None, 204 | decode.optional(decode.string), 205 | ) 206 | use large_text <- decode.optional_field( 207 | "large_text", 208 | None, 209 | decode.optional(decode.string), 210 | ) 211 | use large_url <- decode.optional_field( 212 | "large_url", 213 | None, 214 | decode.optional(decode.string), 215 | ) 216 | use small_image <- decode.optional_field( 217 | "small_image", 218 | None, 219 | decode.optional(decode.string), 220 | ) 221 | use small_text <- decode.optional_field( 222 | "small_text", 223 | None, 224 | decode.optional(decode.string), 225 | ) 226 | use small_url <- decode.optional_field( 227 | "small_url", 228 | None, 229 | decode.optional(decode.string), 230 | ) 231 | decode.success(ActivityAssets( 232 | large_image:, 233 | large_text:, 234 | large_url:, 235 | small_image:, 236 | small_text:, 237 | small_url:, 238 | )) 239 | } 240 | 241 | fn activity_secrets_decoder() { 242 | use join <- decode.optional_field( 243 | "join", 244 | None, 245 | decode.optional(decode.string), 246 | ) 247 | use spectate <- decode.optional_field( 248 | "spectate", 249 | None, 250 | decode.optional(decode.string), 251 | ) 252 | use match <- decode.optional_field( 253 | "match", 254 | None, 255 | decode.optional(decode.string), 256 | ) 257 | decode.success(ActivitySecrets(join:, spectate:, match:)) 258 | } 259 | -------------------------------------------------------------------------------- /src/discord_gleam.gleam: -------------------------------------------------------------------------------- 1 | //// The primary module of discord_gleam. \ 2 | //// This module contains high-level functions to interact with the Discord API. \ 3 | //// But you can always implement stuff yourself using the low-level functions from the rest of the library. \ 4 | 5 | import booklet 6 | import discord_gleam/discord/intents 7 | import discord_gleam/discord/snowflake 8 | import discord_gleam/event_handler 9 | import discord_gleam/http/endpoints 10 | import discord_gleam/internal/error 11 | import discord_gleam/types/bot 12 | import discord_gleam/types/channel 13 | import discord_gleam/types/message 14 | import discord_gleam/types/message_send_response 15 | import discord_gleam/types/reply 16 | import discord_gleam/types/slash_command 17 | import discord_gleam/ws/commands/request_guild_members 18 | import discord_gleam/ws/event_loop 19 | import discord_gleam/ws/packets/interaction_create 20 | import gleam/dict 21 | import gleam/erlang/process 22 | import gleam/list 23 | import gleam/option 24 | import gleam/otp/actor 25 | 26 | /// Create a new bot instance. 27 | /// 28 | /// Example: 29 | /// ```gleam 30 | /// import discord_gleam/discord/intents 31 | /// 32 | /// fn main() { 33 | /// let bot = discord_gleam.bot("TOKEN", "CLIENT_ID", intents.default())) 34 | /// } 35 | /// ``` 36 | pub fn bot( 37 | token: String, 38 | client_id: String, 39 | intents: intents.Intents, 40 | ) -> bot.Bot { 41 | bot.Bot( 42 | token: token, 43 | client_id: client_id, 44 | intents: intents, 45 | cache: bot.Cache(messages: booklet.new(dict.new())), 46 | subject: process.new_subject(), 47 | ) 48 | } 49 | 50 | /// Instruction on how event loop actor should proceed after handling an event 51 | /// 52 | /// - `Continue` - Continue processing with the updated state and optional 53 | /// selector for custom user messages 54 | /// - `Stop` - Stop the event loop 55 | /// - `StopAbnormal` - Stop the event loop with an abnormal reason 56 | pub opaque type Next(user_state, user_message) { 57 | Continue(user_state, option.Option(process.Selector(user_message))) 58 | Stop 59 | StopAbnormal(reason: String) 60 | } 61 | 62 | /// Continue processing with the updated state. Use `with_selector` to add a 63 | /// selector for custom user messages. 64 | pub fn continue(state: user_state) -> Next(user_state, user_message) { 65 | Continue(state, option.None) 66 | } 67 | 68 | /// Add a selector for custom user messages. 69 | pub fn with_selector( 70 | state: Next(user_state, user_message), 71 | selector: process.Selector(user_message), 72 | ) -> Next(user_state, user_message) { 73 | case state { 74 | Continue(user_state, _) -> Continue(user_state, option.Some(selector)) 75 | _ -> state 76 | } 77 | } 78 | 79 | /// Stop the event loop 80 | pub fn stop() -> Next(user_state, user_message) { 81 | Stop 82 | } 83 | 84 | /// Stop the event loop with an abnormal reason 85 | pub fn stop_abnormal(reason: String) -> Next(user_state, user_message) { 86 | StopAbnormal(reason) 87 | } 88 | 89 | /// The mode of the event handler 90 | /// 91 | /// Simple mode is used for simple bots that don't need to handle custom user 92 | /// state and messages. Can have multiple handlers. 93 | /// 94 | /// Normal mode is used for bots that need to handle custom user state and 95 | /// messages. Can have only one handler. 96 | pub opaque type Mode(user_state, user_message) { 97 | Simple( 98 | bot: bot.Bot, 99 | handlers: List(fn(bot.Bot, event_handler.Packet) -> Nil), 100 | next: Next(user_state, user_message), 101 | nil_state: user_state, 102 | ) 103 | Normal( 104 | bot: bot.Bot, 105 | name: process.Name(user_message), 106 | on_init: fn(process.Selector(user_message)) -> 107 | #(user_state, process.Selector(user_message)), 108 | handler: fn(bot.Bot, user_state, HandlerMessage(user_message)) -> 109 | Next(user_state, user_message), 110 | ) 111 | } 112 | 113 | /// The message type for the event handler with custom user messages 114 | pub type HandlerMessage(user_message) { 115 | /// A discord packet 116 | Packet(event_handler.Packet) 117 | /// A custom user message 118 | User(user_message) 119 | } 120 | 121 | /// Create a simple mode with multiple handlers 122 | pub fn simple( 123 | bot: bot.Bot, 124 | handlers: List(fn(bot.Bot, event_handler.Packet) -> Nil), 125 | ) -> Mode(Nil, Nil) { 126 | Simple(bot, handlers, Continue(Nil, option.None), Nil) 127 | } 128 | 129 | /// Create a normal mode with a single handler 130 | /// 131 | /// `on_init` function is called once discord websocket connection is 132 | /// initialized. It must return a tuple with initial state and selector for 133 | /// custom messages. If there is no custom messages, user can pass the same 134 | /// selector from the argument 135 | pub fn new( 136 | bot: bot.Bot, 137 | on_init: fn(process.Selector(user_message)) -> 138 | #(user_state, process.Selector(user_message)), 139 | handler: fn(bot.Bot, user_state, HandlerMessage(user_message)) -> 140 | Next(user_state, user_message), 141 | ) -> Mode(user_state, user_message) { 142 | Normal( 143 | bot, 144 | process.new_name("normal_mode_user_message_subject"), 145 | on_init, 146 | handler, 147 | ) 148 | } 149 | 150 | /// Set process name for the event loop. Allows to use named subjects for custom 151 | /// user messages in normal mode. 152 | pub fn with_name( 153 | mode: Mode(user_state, user_message), 154 | name: process.Name(user_message), 155 | ) -> Mode(user_state, user_message) { 156 | case mode { 157 | Normal(..) -> Normal(..mode, name:) 158 | Simple(..) -> mode 159 | } 160 | } 161 | 162 | /// Start the event loop with a current mode. 163 | /// 164 | /// Example: 165 | /// ```gleam 166 | /// import discord_gleam/discord/intents 167 | /// import discord_gleam/event_handler 168 | /// import gleam/erlang/process 169 | /// 170 | /// fn main() { 171 | /// let bot = discord_gleam.bot("TOKEN", "CLIENT_ID", intents.default()) 172 | /// 173 | /// let assert Ok(_) = 174 | /// discord_gleam.simple(bot, [handler]) 175 | /// |> discord_gleam.start() 176 | /// 177 | /// process.sleep_forever() 178 | /// } 179 | /// 180 | /// fn handler(bot: bot.Bot, packet: event_handler.Packet) { 181 | /// case packet { 182 | /// event_handler.ReadyPacket(ready) -> { 183 | /// logging.log(logging.Info, "Logged in as " <> ready.d.user.username) 184 | /// } 185 | /// 186 | /// _ -> Nil 187 | /// } 188 | /// } 189 | /// ``` 190 | /// 191 | pub fn start( 192 | mode: Mode(user_state, user_message), 193 | ) -> Result( 194 | actor.Started(process.Subject(event_loop.EventLoopMessage)), 195 | actor.StartError, 196 | ) { 197 | let state = booklet.new(dict.new()) 198 | 199 | event_loop.start_event_loop( 200 | to_internal_mode(mode), 201 | "gateway.discord.gg", 202 | False, 203 | "", 204 | state, 205 | ) 206 | } 207 | 208 | fn to_internal_mode( 209 | mode: Mode(user_state, user_message), 210 | ) -> event_handler.Mode(user_state, user_message) { 211 | case mode { 212 | Simple(bot, handlers, next, nil_state) -> 213 | event_handler.Simple(bot, handlers, to_internal_next(next), nil_state) 214 | Normal(bot, name, on_init, handler) -> { 215 | let handler = fn(bot, user_state, msg) { 216 | handler(bot, user_state, internal_to_handler_message(msg)) 217 | |> to_internal_next() 218 | } 219 | 220 | event_handler.Normal(bot, name, on_init, handler) 221 | } 222 | } 223 | } 224 | 225 | fn internal_to_handler_message( 226 | msg: event_handler.HandlerMessage(user_message), 227 | ) -> HandlerMessage(user_message) { 228 | case msg { 229 | event_handler.DiscordPacket(packet) -> Packet(packet) 230 | event_handler.User(msg) -> User(msg) 231 | } 232 | } 233 | 234 | fn to_internal_next( 235 | next: Next(user_state, user_message), 236 | ) -> event_handler.Next(user_state, user_message) { 237 | case next { 238 | Continue(user_state, opt) -> event_handler.Continue(user_state, opt) 239 | Stop -> event_handler.Stop 240 | StopAbnormal(reason) -> event_handler.StopAbnormal(reason) 241 | } 242 | } 243 | 244 | /// Send a message to a channel. 245 | /// 246 | /// Example: 247 | /// ```gleam 248 | /// import discord_gleam 249 | /// 250 | /// fn main() { 251 | /// ... 252 | /// 253 | /// let msg = discord_gleam.send_message( 254 | /// bot, 255 | /// "CHANNEL_ID", 256 | /// "Hello world!", 257 | /// [] // embeds 258 | /// ) 259 | /// } 260 | pub fn send_message( 261 | bot: bot.Bot, 262 | channel_id: String, 263 | message: String, 264 | embeds: List(message.Embed), 265 | ) -> Result(message_send_response.MessageSendResponse, error.DiscordError) { 266 | let msg = message.Message(content: message, embeds: embeds) 267 | 268 | endpoints.send_message(bot.token, channel_id, msg) 269 | } 270 | 271 | /// Create a DM channel with a user. \ 272 | /// Returns a channel object, or a DiscordError if it fails. 273 | pub fn create_dm_channel( 274 | bot: bot.Bot, 275 | user_id: String, 276 | ) -> Result(channel.Channel, error.DiscordError) { 277 | endpoints.create_dm_channel(bot.token, user_id) 278 | } 279 | 280 | /// Send a direct message to a user. \ 281 | /// Same use as `send_message`, but use user_id instead of channel_id. \ 282 | /// `discord_gleam.send_direct_message(bot, "USER_ID", "Hello world!", [])` 283 | /// 284 | /// Note: This will return a DiscordError if the DM channel cant be made 285 | pub fn send_direct_message( 286 | bot: bot.Bot, 287 | user_id: String, 288 | message: String, 289 | embeds: List(message.Embed), 290 | ) -> Result(Nil, error.DiscordError) { 291 | let msg = message.Message(content: message, embeds: embeds) 292 | 293 | endpoints.send_direct_message(bot.token, user_id, msg) 294 | } 295 | 296 | /// Reply to a message in a channel. 297 | /// 298 | /// Example: 299 | /// 300 | /// ```gleam 301 | /// import discord_gleam 302 | /// 303 | /// fn main() { 304 | /// ... 305 | /// 306 | /// discord_gleam.reply(bot, "CHANNEL_ID", "MESSAGE_ID", "Hello world!", []) 307 | /// } 308 | /// ``` 309 | pub fn reply( 310 | bot: bot.Bot, 311 | channel_id: String, 312 | message_id: String, 313 | message: String, 314 | embeds: List(message.Embed), 315 | ) -> Result(Nil, error.DiscordError) { 316 | let msg = 317 | reply.Reply(content: message, message_id: message_id, embeds: embeds) 318 | 319 | endpoints.reply(bot.token, channel_id, msg) 320 | } 321 | 322 | /// Kicks an member from an server. \ 323 | /// The reason will be what is shown in the audit log. 324 | /// 325 | /// Example: 326 | /// 327 | /// ```gleam 328 | /// import discord_gleam 329 | /// 330 | /// fn main() { 331 | /// ... 332 | /// 333 | /// discord_gleam.kick_member(bot, "GUILD_ID", "USER_ID", "REASON") 334 | /// } 335 | /// 336 | /// For an full example, see the `examples/kick.gleam` file. 337 | pub fn kick_member( 338 | bot: bot.Bot, 339 | guild_id: String, 340 | user_id: String, 341 | reason: String, 342 | ) -> Result(Nil, error.DiscordError) { 343 | endpoints.kick_member(bot.token, guild_id, user_id, reason) 344 | } 345 | 346 | pub fn ban_member( 347 | bot: bot.Bot, 348 | guild_id: String, 349 | user_id: String, 350 | reason: String, 351 | ) -> Result(Nil, error.DiscordError) { 352 | endpoints.ban_member(bot.token, guild_id, user_id, reason) 353 | } 354 | 355 | /// Deletes an message from a channel. \ 356 | /// The reason will be what is shown in the audit log. 357 | /// 358 | /// Example: 359 | /// ```gleam 360 | /// import discord_gleam 361 | /// 362 | /// fn main() { 363 | /// ... 364 | /// 365 | /// discord_gleam.delete_message( 366 | /// bot, 367 | /// "CHANNEL_ID", 368 | /// "MESSAGE_ID", 369 | /// "REASON", 370 | /// ) 371 | /// } 372 | /// 373 | /// For an full example, see the `examples/delete_message.gleam` file. 374 | pub fn delete_message( 375 | bot: bot.Bot, 376 | channel_id: String, 377 | message_id: String, 378 | reason: String, 379 | ) -> Result(Nil, error.DiscordError) { 380 | endpoints.delete_message(bot.token, channel_id, message_id, reason) 381 | } 382 | 383 | /// Edits an existing message in a channel. \ 384 | /// The message must have been sent by the bot itself. 385 | pub fn edit_message( 386 | bot: bot.Bot, 387 | channel_id: String, 388 | message_id: String, 389 | content: String, 390 | embeds: List(message.Embed), 391 | ) -> Result(Nil, error.DiscordError) { 392 | let msg = message.Message(content: content, embeds: embeds) 393 | 394 | endpoints.edit_message(bot.token, channel_id, message_id, msg) 395 | } 396 | 397 | /// Wipes all the global slash commands for the bot. \ 398 | /// Restarting your client might be required to see the changes. \ 399 | pub fn wipe_global_commands(bot: bot.Bot) -> Result(Nil, error.DiscordError) { 400 | endpoints.wipe_global_commands(bot.token, bot.client_id) 401 | } 402 | 403 | /// Wipes all the guild slash commands for the bot. \ 404 | /// Restarting your client might be required to see the changes. \ 405 | pub fn wipe_guild_commands( 406 | bot: bot.Bot, 407 | guild_id: String, 408 | ) -> Result(Nil, error.DiscordError) { 409 | endpoints.wipe_guild_commands(bot.token, bot.client_id, guild_id) 410 | } 411 | 412 | /// Registers a global slash command. \ 413 | /// Restarting your client might be required to see the changes. \ 414 | pub fn register_global_commands( 415 | bot: bot.Bot, 416 | commands: List(slash_command.SlashCommand), 417 | ) -> Result(Nil, #(slash_command.SlashCommand, error.DiscordError)) { 418 | list.try_each(commands, fn(command) { 419 | case endpoints.register_global_command(bot.token, bot.client_id, command) { 420 | Ok(_) -> Ok(Nil) 421 | Error(err) -> Error(#(command, err)) 422 | } 423 | }) 424 | } 425 | 426 | /// Registers a guild-specific slash command. \ 427 | /// Restarting your client might be required to see the changes. \ 428 | pub fn register_guild_commands( 429 | bot: bot.Bot, 430 | guild_id: String, 431 | commands: List(slash_command.SlashCommand), 432 | ) -> Result(Nil, #(slash_command.SlashCommand, error.DiscordError)) { 433 | list.try_each(commands, fn(command) { 434 | case 435 | endpoints.register_guild_command( 436 | bot.token, 437 | bot.client_id, 438 | guild_id, 439 | command, 440 | ) 441 | { 442 | Ok(_) -> Ok(Nil) 443 | Error(err) -> Error(#(command, err)) 444 | } 445 | }) 446 | } 447 | 448 | /// Make a basic text reply to an interaction. 449 | pub fn interaction_reply_message( 450 | interaction: interaction_create.InteractionCreatePacket, 451 | message: String, 452 | ephemeral: Bool, 453 | ) -> Result(Nil, error.DiscordError) { 454 | endpoints.interaction_send_text(interaction, message, ephemeral) 455 | } 456 | 457 | /// Used to request all members of a guild. The server will send 458 | /// GUILD_MEMBERS_CHUNK events in response with up to 1000 members per chunk 459 | /// until all members that match the request have been sent. 460 | /// 461 | /// Nonce can only be up to 32 bytes. If you send an invalid nonce it will be 462 | /// ignored and the reply member_chunk(s) will not have a nonce set. 463 | pub fn request_guild_members( 464 | bot: bot.Bot, 465 | guild_id guild_id: snowflake.Snowflake, 466 | option option: request_guild_members.RequestGuildMembersOption, 467 | presences presences: option.Option(Bool), 468 | nonce nonce: option.Option(String), 469 | ) -> Nil { 470 | request_guild_members.request_guild_members( 471 | bot, 472 | guild_id, 473 | option, 474 | presences, 475 | nonce, 476 | ) 477 | } 478 | -------------------------------------------------------------------------------- /src/discord_gleam/event_handler.gleam: -------------------------------------------------------------------------------- 1 | import booklet 2 | import discord_gleam/internal/error 3 | import discord_gleam/types/bot 4 | import gleam/erlang/process 5 | import gleam/option 6 | 7 | import discord_gleam/ws/packets/channel_create 8 | import discord_gleam/ws/packets/channel_delete 9 | import discord_gleam/ws/packets/channel_update 10 | import discord_gleam/ws/packets/generic 11 | import discord_gleam/ws/packets/guild_ban_add 12 | import discord_gleam/ws/packets/guild_ban_remove 13 | import discord_gleam/ws/packets/guild_member_add 14 | import discord_gleam/ws/packets/guild_member_remove 15 | import discord_gleam/ws/packets/guild_member_update 16 | import discord_gleam/ws/packets/guild_members_chunk 17 | import discord_gleam/ws/packets/guild_role_create 18 | import discord_gleam/ws/packets/guild_role_delete 19 | import discord_gleam/ws/packets/guild_role_update 20 | import discord_gleam/ws/packets/interaction_create 21 | import discord_gleam/ws/packets/message 22 | import discord_gleam/ws/packets/message_delete 23 | import discord_gleam/ws/packets/message_delete_bulk 24 | import discord_gleam/ws/packets/message_update 25 | import discord_gleam/ws/packets/presence_update 26 | import discord_gleam/ws/packets/ready 27 | import gleam/dict 28 | import gleam/list 29 | import logging 30 | 31 | /// The message type for the event handler with custom user messages 32 | pub type HandlerMessage(user_message) { 33 | DiscordPacket(Packet) 34 | User(user_message) 35 | } 36 | 37 | /// The message type received from event loop 38 | pub type InternalMessage(user_message) { 39 | InternalPacket(String) 40 | InternalUser(user_message) 41 | } 42 | 43 | /// Instruction on how event loop actor should proceed after handling an event 44 | pub type Next(new_state, user_message) { 45 | Continue(new_state, option.Option(process.Selector(user_message))) 46 | Stop 47 | StopAbnormal(reason: String) 48 | } 49 | 50 | /// The mode of the event handler 51 | /// 52 | /// Simple mode is used for simple bots that don't need to handle custom user 53 | /// state and messages. `default_next` and `nil_state` fields are required for 54 | /// proper type inference. Recommended to use `Nil` state and continue with no 55 | /// selector. 56 | /// 57 | /// Normal mode is used for bots that need to handle custom user state 58 | /// and messages. 59 | pub type Mode(user_state, user_message) { 60 | Simple( 61 | bot: bot.Bot, 62 | handlers: List(fn(bot.Bot, Packet) -> Nil), 63 | default_next: Next(user_state, user_message), 64 | nil_state: user_state, 65 | ) 66 | Normal( 67 | bot: bot.Bot, 68 | name: process.Name(user_message), 69 | on_init: fn(process.Selector(user_message)) -> 70 | #(user_state, process.Selector(user_message)), 71 | handler: fn(bot.Bot, user_state, HandlerMessage(user_message)) -> 72 | Next(user_state, user_message), 73 | ) 74 | } 75 | 76 | /// Check if the mode is normal mode 77 | pub fn name_from_mode( 78 | mode: Mode(user_state, user_message), 79 | ) -> Result(process.Name(user_message), Nil) { 80 | case mode { 81 | Normal(name:, ..) -> Ok(name) 82 | Simple(..) -> Error(Nil) 83 | } 84 | } 85 | 86 | /// Get the bot from all possible modes 87 | pub fn bot_from_mode(mode: Mode(user_state, user_message)) -> bot.Bot { 88 | case mode { 89 | Simple(bot, ..) -> bot 90 | Normal(bot, ..) -> bot 91 | } 92 | } 93 | 94 | pub fn set_bot( 95 | mode: Mode(user_state, user_message), 96 | bot: bot.Bot, 97 | ) -> Mode(user_state, user_message) { 98 | case mode { 99 | Simple(..) -> Simple(..mode, bot:) 100 | Normal(..) -> Normal(..mode, bot:) 101 | } 102 | } 103 | 104 | /// The supported discord packets 105 | pub type Packet { 106 | /// `READY` event 107 | ReadyPacket(ready.ReadyPacket) 108 | 109 | /// `INTERACTION_CREATE` event 110 | InteractionCreatePacket(interaction_create.InteractionCreatePacket) 111 | 112 | /// `MESSAGE_DELETE` event 113 | MessageDeletePacket(message_delete.MessageDeletePacket) 114 | /// `MESSAGE_CREATE` event 115 | MessagePacket(message.MessagePacket) 116 | /// `MESSAGE_UPDATE` event 117 | MessageUpdatePacket(message_update.MessageUpdatePacket) 118 | /// `MESSAGE_DELETE_BULK` event 119 | MessageDeleteBulkPacket(message_delete_bulk.MessageDeleteBulkPacket) 120 | 121 | /// `CHANNEL_CREATE` event 122 | ChannelCreatePacket(channel_create.ChannelCreatePacket) 123 | /// `CHANNEL_DELETE` event 124 | ChannelDeletePacket(channel_delete.ChannelDeletePacket) 125 | /// `CHANNEL_UPDATE` event 126 | ChannelUpdatePacket(channel_update.ChannelUpdatePacket) 127 | 128 | /// `GUILD_BAN_ADD` event 129 | GuildBanAddPacket(guild_ban_add.GuildBanAddPacket) 130 | /// `GUILD_BAN_REMOVE` event 131 | GuildBanRemovePacket(guild_ban_remove.GuildBanRemovePacket) 132 | 133 | /// `GUILD_ROLE_CREATE` event 134 | GuildRoleCreatePacket(guild_role_create.GuildRoleCreatePacket) 135 | /// `GUILD_ROLE_UPDATE` event 136 | GuildRoleUpdatePacket(guild_role_update.GuildRoleUpdatePacket) 137 | /// `GUILD_ROLE_DELETE` event 138 | GuildRoleDeletePacket(guild_role_delete.GuildRoleDeletePacket) 139 | 140 | /// `GUILD_MEMBER_ADD` event 141 | GuildMemberAddPacket(guild_member_add.GuildMemberAdd) 142 | /// `GUILD_MEMBER_UPDATE` event 143 | GuildMemberUpdatePacket(guild_member_update.GuildMemberUpdate) 144 | /// GUILD_MEMBER_REMOVE event 145 | GuildMemberRemovePacket(guild_member_remove.GuildMemberRemove) 146 | /// `GUILD_MEMBERS_CHUNK` event 147 | GuildMembersChunkPacket(guild_members_chunk.GuildMembersChunkPacket) 148 | 149 | /// `PRESENCE_UPDATE` event 150 | PresenceUpdatePacket(presence_update.PresenceUpdatePacket) 151 | 152 | /// When we receive a packet that we don't know how to handle 153 | UnknownPacket(generic.GenericPacket) 154 | } 155 | 156 | /// For handling some events the library needs to handle, for functionality 157 | fn internal_handler( 158 | bot: bot.Bot, 159 | packet: Packet, 160 | state_ets: booklet.Booklet(dict.Dict(String, String)), 161 | ) -> Nil { 162 | case packet { 163 | MessagePacket(msg) -> { 164 | booklet.update(bot.cache.messages, fn(cache) { 165 | dict.insert(cache, msg.d.id, msg.d) 166 | }) 167 | 168 | Nil 169 | } 170 | 171 | MessageUpdatePacket(msg) -> { 172 | booklet.update(bot.cache.messages, fn(cache) { 173 | dict.insert(cache, msg.d.id, msg.d) 174 | }) 175 | 176 | Nil 177 | } 178 | 179 | ReadyPacket(ready) -> { 180 | booklet.update(state_ets, fn(cache) { 181 | let cache = dict.insert(cache, "session_id", ready.d.session_id) 182 | 183 | dict.insert(cache, "resume_gateway_url", ready.d.resume_gateway_url) 184 | }) 185 | 186 | Nil 187 | } 188 | 189 | _ -> Nil 190 | } 191 | } 192 | 193 | /// Handle an event from the Discord API, using a current handler mode, state 194 | /// and internal message. 195 | pub fn handle_event( 196 | bot: bot.Bot, 197 | user_state: user_state, 198 | msg: InternalMessage(user_message), 199 | mode: Mode(user_state, user_message), 200 | state_ets: booklet.Booklet(dict.Dict(String, String)), 201 | ) -> Next(user_state, user_message) { 202 | case msg { 203 | InternalPacket(packet) -> { 204 | let packet = decode_packet(packet) 205 | internal_handler(bot, packet, state_ets) 206 | 207 | case mode { 208 | Simple(bot, handlers, next, _nil_state) -> { 209 | list.each(handlers, fn(handler) { handler(bot, packet) }) 210 | next 211 | } 212 | Normal(bot, _name, _on_init, handler) -> { 213 | handler(bot, user_state, DiscordPacket(packet)) 214 | } 215 | } 216 | } 217 | InternalUser(msg) -> { 218 | let assert Normal(bot, _name, _on_init, handler) = mode 219 | handler(bot, user_state, User(msg)) 220 | } 221 | } 222 | } 223 | 224 | fn decode_packet(msg: String) -> Packet { 225 | let generic_packet = generic.string_to_data(msg) 226 | 227 | case generic_packet.t { 228 | "READY" -> 229 | case ready.string_to_data(msg) { 230 | Ok(data) -> ReadyPacket(data) 231 | Error(err) -> { 232 | logging.log( 233 | logging.Error, 234 | "Failed to decode READY packet: " 235 | <> error.json_decode_error_to_string(err), 236 | ) 237 | 238 | UnknownPacket(generic_packet) 239 | } 240 | } 241 | 242 | "MESSAGE_CREATE" -> 243 | case message.string_to_data(msg) { 244 | Ok(data) -> MessagePacket(data) 245 | Error(err) -> { 246 | logging.log( 247 | logging.Error, 248 | "Failed to decode MESSAGE_CREATE packet: " 249 | <> error.json_decode_error_to_string(err), 250 | ) 251 | 252 | UnknownPacket(generic_packet) 253 | } 254 | } 255 | 256 | "MESSAGE_UPDATE" -> 257 | case message_update.string_to_data(msg) { 258 | Ok(data) -> MessageUpdatePacket(data) 259 | Error(err) -> { 260 | logging.log( 261 | logging.Error, 262 | "Failed to decode MESSAGE_UPDATE packet: " 263 | <> error.json_decode_error_to_string(err), 264 | ) 265 | 266 | UnknownPacket(generic_packet) 267 | } 268 | } 269 | 270 | "MESSAGE_DELETE" -> 271 | case message_delete.string_to_data(msg) { 272 | Ok(data) -> MessageDeletePacket(data) 273 | Error(err) -> { 274 | logging.log( 275 | logging.Error, 276 | "Failed to decode MESSAGE_DELETE packet: " 277 | <> error.json_decode_error_to_string(err), 278 | ) 279 | 280 | UnknownPacket(generic_packet) 281 | } 282 | } 283 | 284 | "MESSAGE_DELETE_BULK" -> 285 | case message_delete_bulk.string_to_data(msg) { 286 | Ok(data) -> MessageDeleteBulkPacket(data) 287 | Error(err) -> { 288 | logging.log( 289 | logging.Error, 290 | "Failed to decode MESSAGE_DELETE_BULK packet: " 291 | <> error.json_decode_error_to_string(err), 292 | ) 293 | 294 | UnknownPacket(generic_packet) 295 | } 296 | } 297 | 298 | "INTERACTION_CREATE" -> 299 | case interaction_create.string_to_data(msg) { 300 | Ok(data) -> InteractionCreatePacket(data) 301 | Error(err) -> { 302 | logging.log( 303 | logging.Error, 304 | "Failed to decode INTERACTION_CREATE packet: " 305 | <> error.json_decode_error_to_string(err), 306 | ) 307 | 308 | UnknownPacket(generic_packet) 309 | } 310 | } 311 | 312 | "CHANNEL_CREATE" -> 313 | case channel_create.string_to_data(msg) { 314 | Ok(data) -> ChannelCreatePacket(data) 315 | Error(err) -> { 316 | logging.log( 317 | logging.Error, 318 | "Failed to decode CHANNEL_CREATE packet: " 319 | <> error.json_decode_error_to_string(err), 320 | ) 321 | 322 | UnknownPacket(generic_packet) 323 | } 324 | } 325 | 326 | "CHANNEL_DELETE" -> 327 | case channel_delete.string_to_data(msg) { 328 | Ok(data) -> ChannelDeletePacket(data) 329 | Error(err) -> { 330 | logging.log( 331 | logging.Error, 332 | "Failed to decode CHANNEL_DELETE packet: " 333 | <> error.json_decode_error_to_string(err), 334 | ) 335 | 336 | UnknownPacket(generic_packet) 337 | } 338 | } 339 | 340 | "CHANNEL_UPDATE" -> 341 | case channel_update.string_to_data(msg) { 342 | Ok(data) -> ChannelUpdatePacket(data) 343 | Error(err) -> { 344 | logging.log( 345 | logging.Error, 346 | "Failed to decode CHANNEL_UPDATE packet: " 347 | <> error.json_decode_error_to_string(err), 348 | ) 349 | 350 | UnknownPacket(generic_packet) 351 | } 352 | } 353 | 354 | "GUILD_BAN_ADD" -> 355 | case guild_ban_add.string_to_data(msg) { 356 | Ok(data) -> GuildBanAddPacket(data) 357 | Error(err) -> { 358 | logging.log( 359 | logging.Error, 360 | "Failed to decode GUILD_BAN_ADD packet: " 361 | <> error.json_decode_error_to_string(err), 362 | ) 363 | 364 | UnknownPacket(generic_packet) 365 | } 366 | } 367 | 368 | "GUILD_BAN_REMOVE" -> 369 | case guild_ban_remove.string_to_data(msg) { 370 | Ok(data) -> GuildBanRemovePacket(data) 371 | Error(err) -> { 372 | logging.log( 373 | logging.Error, 374 | "Failed to decode GUILD_BAN_REMOVE packet: " 375 | <> error.json_decode_error_to_string(err), 376 | ) 377 | 378 | UnknownPacket(generic_packet) 379 | } 380 | } 381 | 382 | "GUILD_ROLE_CREATE" -> 383 | case guild_role_create.string_to_data(msg) { 384 | Ok(data) -> GuildRoleCreatePacket(data) 385 | Error(err) -> { 386 | logging.log( 387 | logging.Error, 388 | "Failed to decode GUILD_ROLE_CREATE packet: " 389 | <> error.json_decode_error_to_string(err), 390 | ) 391 | 392 | UnknownPacket(generic_packet) 393 | } 394 | } 395 | 396 | "GUILD_ROLE_UPDATE" -> 397 | case guild_role_update.string_to_data(msg) { 398 | Ok(data) -> GuildRoleUpdatePacket(data) 399 | Error(err) -> { 400 | logging.log( 401 | logging.Error, 402 | "Failed to decode GUILD_ROLE_UPDATE packet: " 403 | <> error.json_decode_error_to_string(err), 404 | ) 405 | 406 | UnknownPacket(generic_packet) 407 | } 408 | } 409 | 410 | "GUILD_ROLE_DELETE" -> 411 | case guild_role_delete.string_to_data(msg) { 412 | Ok(data) -> GuildRoleDeletePacket(data) 413 | Error(err) -> { 414 | logging.log( 415 | logging.Error, 416 | "Failed to decode GUILD_ROLE_DELETE packet: " 417 | <> error.json_decode_error_to_string(err), 418 | ) 419 | 420 | UnknownPacket(generic_packet) 421 | } 422 | } 423 | 424 | "GUILD_MEMBER_ADD" -> 425 | case guild_member_add.string_to_data(msg) { 426 | Ok(data) -> GuildMemberAddPacket(data) 427 | Error(err) -> { 428 | logging.log( 429 | logging.Error, 430 | "Failed to decode GUILD_MEMBER_ADD packet: " 431 | <> error.json_decode_error_to_string(err), 432 | ) 433 | 434 | UnknownPacket(generic_packet) 435 | } 436 | } 437 | 438 | "GUILD_MEMBER_UPDATE" -> 439 | case guild_member_update.string_to_data(msg) { 440 | Ok(data) -> GuildMemberUpdatePacket(data) 441 | Error(err) -> { 442 | logging.log( 443 | logging.Error, 444 | "Failed to decode GUILD_MEMBER_UPDATE packet: " 445 | <> error.json_decode_error_to_string(err), 446 | ) 447 | 448 | UnknownPacket(generic_packet) 449 | } 450 | } 451 | 452 | "GUILD_MEMBER_REMOVE" -> 453 | case guild_member_remove.string_to_data(msg) { 454 | Ok(data) -> GuildMemberRemovePacket(data) 455 | Error(err) -> { 456 | logging.log( 457 | logging.Error, 458 | "Failed to decode GUILD_MEMBER_REMOVE packet: " 459 | <> error.json_decode_error_to_string(err), 460 | ) 461 | 462 | UnknownPacket(generic_packet) 463 | } 464 | } 465 | 466 | "GUILD_MEMBERS_CHUNK" -> 467 | case guild_members_chunk.string_to_data(msg) { 468 | Ok(data) -> GuildMembersChunkPacket(data) 469 | Error(err) -> { 470 | logging.log( 471 | logging.Error, 472 | "Failed to decode GUILD_MEMBERS_CHUNK packet: " 473 | <> error.json_decode_error_to_string(err), 474 | ) 475 | UnknownPacket(generic_packet) 476 | } 477 | } 478 | 479 | "PRESENCE_UPDATE" -> 480 | case presence_update.string_to_data(msg) { 481 | Ok(data) -> PresenceUpdatePacket(data) 482 | Error(err) -> { 483 | logging.log( 484 | logging.Error, 485 | "Failed to decode PRESENCE_UPDATE packet: " 486 | <> error.json_decode_error_to_string(err), 487 | ) 488 | 489 | UnknownPacket(generic_packet) 490 | } 491 | } 492 | 493 | _ -> UnknownPacket(generic_packet) 494 | } 495 | } 496 | -------------------------------------------------------------------------------- /src/discord_gleam/http/endpoints.gleam: -------------------------------------------------------------------------------- 1 | //// Low-level functions for interacting with the Discord API. \ 2 | //// Preferrably use the higher-level functions in src/discord_gleam.gleam. 3 | 4 | import discord_gleam/http/request 5 | import discord_gleam/internal/error 6 | import discord_gleam/types/channel 7 | import discord_gleam/types/message 8 | import discord_gleam/types/message_send_response 9 | import discord_gleam/types/reply 10 | import discord_gleam/types/slash_command 11 | import discord_gleam/types/user 12 | import discord_gleam/ws/packets/interaction_create 13 | import gleam/dynamic/decode 14 | import gleam/http 15 | import gleam/http/response 16 | import gleam/httpc 17 | import gleam/json 18 | import logging 19 | 20 | /// Get the current user 21 | pub fn me(token: String) -> Result(user.User, error.DiscordError) { 22 | let request = request.new_auth(http.Get, "/users/@me", token) 23 | case httpc.send(request) { 24 | Ok(resp) -> { 25 | case response.get_header(resp, "content-type") { 26 | Ok("application/json") -> { 27 | user.string_to_data(resp.body) 28 | } 29 | _ -> 30 | Error( 31 | error.InvalidFormat( 32 | decode.DecodeError( 33 | expected: "application/json content-type", 34 | found: "unknown", 35 | path: [], 36 | ), 37 | ), 38 | ) 39 | } 40 | } 41 | 42 | Error(err) -> { 43 | logging.log(logging.Error, "Failed to get current user") 44 | 45 | Error(error.HttpError(err)) 46 | } 47 | } 48 | } 49 | 50 | /// Send a message to a channel 51 | pub fn send_message( 52 | token: String, 53 | channel_id: String, 54 | message: message.Message, 55 | ) -> Result(message_send_response.MessageSendResponse, error.DiscordError) { 56 | let data = message.to_string(message) 57 | 58 | logging.log(logging.Debug, "Sending message: " <> data) 59 | 60 | let request = 61 | request.new_auth_with_body( 62 | http.Post, 63 | "/channels/" <> channel_id <> "/messages", 64 | token, 65 | data, 66 | ) 67 | 68 | case httpc.send(request) { 69 | Ok(resp) -> { 70 | case resp.status { 71 | 200 -> { 72 | logging.log(logging.Debug, "Message sent") 73 | 74 | message_send_response.from_json_string(resp.body) 75 | } 76 | 77 | _ -> { 78 | logging.log(logging.Error, "Failed to send message") 79 | 80 | Error(error.GenericHttpError( 81 | status_code: resp.status, 82 | body: resp.body, 83 | )) 84 | } 85 | } 86 | } 87 | 88 | Error(err) -> { 89 | logging.log(logging.Error, "Failed to send message") 90 | 91 | Error(error.HttpError(err)) 92 | } 93 | } 94 | } 95 | 96 | /// Create a DM channel, can be used to send direct messages where a direct message function is not created 97 | pub fn create_dm_channel( 98 | token: String, 99 | user_id: String, 100 | ) -> Result(channel.Channel, error.DiscordError) { 101 | let request = 102 | request.new_auth_with_body( 103 | http.Post, 104 | "/users/@me/channels", 105 | token, 106 | json.to_string(json.object([#("recipient_id", json.string(user_id))])), 107 | ) 108 | 109 | case httpc.send(request) { 110 | Ok(resp) -> { 111 | case resp.status { 112 | 200 -> { 113 | logging.log(logging.Debug, "DM channel created") 114 | 115 | let channel: Result(channel.Channel, error.DiscordError) = 116 | channel.string_to_data(resp.body) 117 | 118 | case channel { 119 | Ok(channel) -> { 120 | Ok(channel) 121 | } 122 | 123 | Error(err) -> { 124 | logging.log(logging.Error, "Failed to decode DM channel") 125 | 126 | Error(err) 127 | } 128 | } 129 | } 130 | 131 | v -> { 132 | Error(error.GenericHttpError(status_code: v, body: resp.body)) 133 | } 134 | } 135 | } 136 | 137 | Error(err) -> { 138 | logging.log(logging.Error, "Failed to create DM channel") 139 | 140 | Error(error.HttpError(err)) 141 | } 142 | } 143 | } 144 | 145 | /// Creates a DM channel, then sends a message with `send_message()`. 146 | pub fn send_direct_message( 147 | token: String, 148 | user_id: String, 149 | message: message.Message, 150 | ) -> Result(Nil, error.DiscordError) { 151 | let data: String = message.to_string(message) 152 | logging.log(logging.Debug, "Sending DM: " <> data) 153 | 154 | let channel: Result(channel.Channel, error.DiscordError) = 155 | create_dm_channel(token, user_id) 156 | 157 | case channel { 158 | Ok(channel) -> { 159 | let _ = send_message(token, channel.id, message) 160 | 161 | Ok(Nil) 162 | } 163 | 164 | Error(err) -> { 165 | logging.log(logging.Error, "Failed to create DM channel") 166 | 167 | Error(err) 168 | } 169 | } 170 | } 171 | 172 | /// Reply to a message 173 | pub fn reply( 174 | token: String, 175 | channel_id: String, 176 | message: reply.Reply, 177 | ) -> Result(Nil, error.DiscordError) { 178 | let data = reply.to_string(message) 179 | 180 | logging.log(logging.Debug, "Replying: " <> data) 181 | 182 | let request = 183 | request.new_auth_with_body( 184 | http.Post, 185 | "/channels/" <> channel_id <> "/messages", 186 | token, 187 | data, 188 | ) 189 | 190 | case httpc.send(request) { 191 | Ok(resp) -> { 192 | case resp.status { 193 | 200 -> { 194 | logging.log(logging.Debug, "Reply sent") 195 | 196 | Ok(Nil) 197 | } 198 | _ -> { 199 | logging.log(logging.Error, "Failed to send reply") 200 | 201 | Error(error.GenericHttpError( 202 | status_code: resp.status, 203 | body: resp.body, 204 | )) 205 | } 206 | } 207 | } 208 | Error(err) -> { 209 | logging.log(logging.Error, "Failed to send reply") 210 | 211 | Error(error.HttpError(err)) 212 | } 213 | } 214 | } 215 | 216 | /// Kick a member from a server 217 | pub fn kick_member( 218 | token: String, 219 | guild_id: String, 220 | user_id: String, 221 | reason: String, 222 | ) -> Result(Nil, error.DiscordError) { 223 | let request = 224 | request.new_auth_with_header( 225 | http.Delete, 226 | "/guilds/" <> guild_id <> "/members/" <> user_id, 227 | token, 228 | #("X-Audit-Log-Reason", reason), 229 | ) 230 | 231 | case httpc.send(request) { 232 | Ok(resp) -> { 233 | case resp.status { 234 | 204 -> { 235 | logging.log(logging.Debug, "Kicked member") 236 | 237 | Ok(Nil) 238 | } 239 | 240 | _ -> { 241 | logging.log(logging.Error, "Failed to kick member") 242 | 243 | Error(error.GenericHttpError( 244 | status_code: resp.status, 245 | body: resp.body, 246 | )) 247 | } 248 | } 249 | } 250 | Error(err) -> { 251 | logging.log(logging.Error, "Failed to kick member") 252 | 253 | Error(error.HttpError(err)) 254 | } 255 | } 256 | } 257 | 258 | /// Ban a member from a server 259 | pub fn ban_member( 260 | token: String, 261 | guild_id: String, 262 | user_id: String, 263 | reason: String, 264 | ) -> Result(Nil, error.DiscordError) { 265 | let request = 266 | request.new_auth_with_header( 267 | http.Put, 268 | "/guilds/" <> guild_id <> "/bans/" <> user_id, 269 | token, 270 | #("X-Audit-Log-Reason", reason), 271 | ) 272 | 273 | case httpc.send(request) { 274 | Ok(resp) -> { 275 | case resp.status { 276 | 204 -> { 277 | logging.log(logging.Debug, "Banned member") 278 | 279 | Ok(Nil) 280 | } 281 | _ -> { 282 | logging.log(logging.Error, "Failed to ban member") 283 | 284 | Error(error.GenericHttpError( 285 | status_code: resp.status, 286 | body: resp.body, 287 | )) 288 | } 289 | } 290 | } 291 | Error(err) -> { 292 | logging.log(logging.Error, "Failed to ban member") 293 | 294 | Error(error.HttpError(err)) 295 | } 296 | } 297 | } 298 | 299 | /// Delete a message by channel id and message id 300 | pub fn delete_message( 301 | token: String, 302 | channel_id: String, 303 | message_id: String, 304 | reason: String, 305 | ) -> Result(Nil, error.DiscordError) { 306 | let request = 307 | request.new_auth_with_header( 308 | http.Delete, 309 | "/channels/" <> channel_id <> "/messages/" <> message_id, 310 | token, 311 | #("X-Audit-Log-Reason", reason), 312 | ) 313 | 314 | case httpc.send(request) { 315 | Ok(resp) -> { 316 | case resp.status { 317 | 204 -> { 318 | logging.log(logging.Debug, "Deleted Message") 319 | 320 | Ok(Nil) 321 | } 322 | _ -> { 323 | logging.log(logging.Error, "Failed to delete message") 324 | 325 | Error(error.GenericHttpError( 326 | status_code: resp.status, 327 | body: resp.body, 328 | )) 329 | } 330 | } 331 | } 332 | Error(err) -> { 333 | logging.log(logging.Error, "Failed to delete message") 334 | 335 | Error(error.HttpError(err)) 336 | } 337 | } 338 | } 339 | 340 | /// Edit an message by channel id and message id 341 | pub fn edit_message( 342 | token: String, 343 | channel_id: String, 344 | message_id: String, 345 | message: message.Message, 346 | ) -> Result(Nil, error.DiscordError) { 347 | let data = message.to_string(message) 348 | 349 | logging.log(logging.Debug, "Editing message: " <> data) 350 | 351 | let request = 352 | request.new_auth_with_body( 353 | http.Patch, 354 | "/channels/" <> channel_id <> "/messages/" <> message_id, 355 | token, 356 | data, 357 | ) 358 | 359 | case httpc.send(request) { 360 | Ok(resp) -> { 361 | case resp.status { 362 | 200 -> { 363 | logging.log(logging.Debug, "Message edited") 364 | 365 | Ok(Nil) 366 | } 367 | _ -> { 368 | logging.log(logging.Error, "Failed to edit message") 369 | 370 | Error(error.GenericHttpError( 371 | status_code: resp.status, 372 | body: resp.body, 373 | )) 374 | } 375 | } 376 | } 377 | 378 | Error(err) -> { 379 | logging.log(logging.Error, "Failed to edit message") 380 | 381 | Error(error.HttpError(err)) 382 | } 383 | } 384 | } 385 | 386 | /// Wipes the global commands for the bot 387 | pub fn wipe_global_commands( 388 | token: String, 389 | client_id: String, 390 | ) -> Result(Nil, error.DiscordError) { 391 | let request = 392 | request.new_auth_with_body( 393 | http.Put, 394 | "/applications/" <> client_id <> "/commands", 395 | token, 396 | "{}", 397 | ) 398 | 399 | case httpc.send(request) { 400 | Ok(resp) -> { 401 | case resp.status { 402 | 200 -> { 403 | logging.log(logging.Debug, "Wiped global commands") 404 | 405 | Ok(Nil) 406 | } 407 | _ -> { 408 | logging.log(logging.Error, "Failed to wipe global commands") 409 | 410 | Error(error.GenericHttpError( 411 | status_code: resp.status, 412 | body: resp.body, 413 | )) 414 | } 415 | } 416 | } 417 | Error(err) -> { 418 | logging.log(logging.Error, "Failed to wipe global commands") 419 | 420 | Error(error.HttpError(err)) 421 | } 422 | } 423 | } 424 | 425 | /// Wipes the guild commands for the bot 426 | pub fn wipe_guild_commands( 427 | token: String, 428 | client_id: String, 429 | guild_id: String, 430 | ) -> Result(Nil, error.DiscordError) { 431 | let request = 432 | request.new_auth_with_body( 433 | http.Put, 434 | "/applications/" <> client_id <> "/guilds/" <> guild_id <> "/commands", 435 | token, 436 | "{}", 437 | ) 438 | 439 | case httpc.send(request) { 440 | Ok(resp) -> { 441 | case resp.status { 442 | 200 -> { 443 | logging.log(logging.Debug, "Wiped guild commands") 444 | 445 | Ok(Nil) 446 | } 447 | _ -> { 448 | logging.log(logging.Error, "Failed to wipe guild commands") 449 | 450 | Error(error.GenericHttpError( 451 | status_code: resp.status, 452 | body: resp.body, 453 | )) 454 | } 455 | } 456 | } 457 | Error(err) -> { 458 | logging.log(logging.Error, "Failed to wipe guild commands") 459 | 460 | Error(error.HttpError(err)) 461 | } 462 | } 463 | } 464 | 465 | /// Register a new global slash command 466 | pub fn register_global_command( 467 | token: String, 468 | client_id: String, 469 | command: slash_command.SlashCommand, 470 | ) -> Result(Nil, error.DiscordError) { 471 | let request = 472 | request.new_auth_with_body( 473 | http.Post, 474 | "/applications/" <> client_id <> "/commands", 475 | token, 476 | slash_command.command_to_string(command), 477 | ) 478 | 479 | case httpc.send(request) { 480 | Ok(resp) -> { 481 | case resp.status { 482 | 201 -> { 483 | logging.log(logging.Debug, "Added global command " <> command.name) 484 | 485 | Ok(Nil) 486 | } 487 | 488 | 200 -> { 489 | logging.log(logging.Debug, "Updated global command " <> command.name) 490 | 491 | Ok(Nil) 492 | } 493 | 494 | _ -> { 495 | logging.log( 496 | logging.Error, 497 | "Failed to add global command " <> command.name, 498 | ) 499 | 500 | Error(error.GenericHttpError( 501 | status_code: resp.status, 502 | body: resp.body, 503 | )) 504 | } 505 | } 506 | } 507 | 508 | Error(err) -> { 509 | logging.log( 510 | logging.Error, 511 | "Failed to add global command " <> command.name, 512 | ) 513 | 514 | Error(error.HttpError(err)) 515 | } 516 | } 517 | } 518 | 519 | /// Register a new guild-specific slash command 520 | pub fn register_guild_command( 521 | token: String, 522 | client_id: String, 523 | guild_id: String, 524 | command: slash_command.SlashCommand, 525 | ) -> Result(Nil, error.DiscordError) { 526 | let request = 527 | request.new_auth_with_body( 528 | http.Post, 529 | "/applications/" <> client_id <> "/guilds/" <> guild_id <> "/commands", 530 | token, 531 | slash_command.command_to_string(command), 532 | ) 533 | 534 | case httpc.send(request) { 535 | Ok(resp) -> { 536 | case resp.status { 537 | 201 -> { 538 | logging.log(logging.Debug, "Added guild command " <> command.name) 539 | 540 | Ok(Nil) 541 | } 542 | 543 | 200 -> { 544 | logging.log(logging.Debug, "Updated guild command " <> command.name) 545 | 546 | Ok(Nil) 547 | } 548 | 549 | _ -> { 550 | logging.log( 551 | logging.Error, 552 | "Failed to add guild command " <> command.name, 553 | ) 554 | 555 | Error(error.GenericHttpError( 556 | status_code: resp.status, 557 | body: resp.body, 558 | )) 559 | } 560 | } 561 | } 562 | Error(err) -> { 563 | logging.log(logging.Error, "Failed to add guild command " <> command.name) 564 | 565 | Error(error.HttpError(err)) 566 | } 567 | } 568 | } 569 | 570 | /// Send a basic text reply to an interaction 571 | pub fn interaction_send_text( 572 | interaction: interaction_create.InteractionCreatePacket, 573 | message: String, 574 | ephemeral: Bool, 575 | ) -> Result(Nil, error.DiscordError) { 576 | let request = 577 | request.new_with_body( 578 | http.Post, 579 | "/interactions/" 580 | <> interaction.d.id 581 | <> "/" 582 | <> interaction.d.token 583 | <> "/callback", 584 | slash_command.make_basic_text_reply(message, ephemeral), 585 | ) 586 | 587 | case httpc.send(request) { 588 | Ok(resp) -> { 589 | case resp.status { 590 | 204 -> { 591 | logging.log(logging.Debug, "Sent Interaction Response") 592 | 593 | Ok(Nil) 594 | } 595 | 596 | _ -> { 597 | logging.log(logging.Error, "Failed to send Interaction Response") 598 | 599 | Error(error.GenericHttpError( 600 | status_code: resp.status, 601 | body: resp.body, 602 | )) 603 | } 604 | } 605 | } 606 | Error(err) -> { 607 | logging.log(logging.Error, "Error when sending Interaction Response") 608 | 609 | Error(error.HttpError(err)) 610 | } 611 | } 612 | } 613 | -------------------------------------------------------------------------------- /src/discord_gleam/ws/event_loop.gleam: -------------------------------------------------------------------------------- 1 | //// Event loop for handling the discord gateway websocket 2 | //// Dispatches events to registered event handlers 3 | 4 | import booklet 5 | import discord_gleam/event_handler 6 | import discord_gleam/internal/error 7 | import discord_gleam/types/bot 8 | import discord_gleam/ws/packets/generic 9 | import discord_gleam/ws/packets/hello 10 | import discord_gleam/ws/packets/identify 11 | import gleam/dict 12 | import gleam/erlang/process 13 | import gleam/http 14 | import gleam/http/request 15 | import gleam/int 16 | import gleam/json 17 | import gleam/option 18 | import gleam/otp/actor 19 | import gleam/result 20 | import logging 21 | import repeatedly 22 | import stratus 23 | 24 | /// The message type for the event loop actor 25 | pub type EventLoopMessage { 26 | Start 27 | Restart(host: String, session_id: String) 28 | Stop 29 | } 30 | 31 | /// Start the event loop, with a set of event handlers. 32 | pub fn start_event_loop( 33 | mode: event_handler.Mode(user_state, user_message), 34 | host: String, 35 | reconnect: Bool, 36 | session_id: String, 37 | state_ets: booklet.Booklet(dict.Dict(String, String)), 38 | ) { 39 | logging.log(logging.Debug, "Starting event loop") 40 | 41 | actor.new_with_initialiser(1000, fn(subject) { 42 | logging.log(logging.Debug, "Sending start message") 43 | actor.send(subject, Start) 44 | 45 | actor.initialised(subject) 46 | |> actor.returning(subject) 47 | |> Ok 48 | }) 49 | |> actor.on_message(fn(subject, msg) { 50 | case msg { 51 | Start -> { 52 | logging.log(logging.Debug, "Received start message") 53 | let started = 54 | start_discord_websocket( 55 | mode, 56 | subject, 57 | host, 58 | reconnect, 59 | session_id, 60 | state_ets, 61 | ) 62 | 63 | case started { 64 | Ok(Nil) -> actor.continue(subject) 65 | Error(_actor_failed) -> 66 | actor.stop_abnormal("failed to start discord websocket") 67 | } 68 | } 69 | Restart(host, session_id) -> { 70 | logging.log(logging.Debug, "Restarting discord websocket") 71 | let started = 72 | start_discord_websocket( 73 | mode, 74 | subject, 75 | host, 76 | reconnect, 77 | session_id, 78 | state_ets, 79 | ) 80 | 81 | case started { 82 | Ok(Nil) -> actor.continue(subject) 83 | Error(_actor_failed) -> 84 | actor.stop_abnormal("failed to restart discord websocket") 85 | } 86 | } 87 | Stop -> actor.stop() 88 | } 89 | }) 90 | |> actor.start() 91 | } 92 | 93 | pub type WebsocketState(user_state, user_message) { 94 | State( 95 | has_received_hello: Bool, 96 | s: Int, 97 | event_loop_subject: process.Subject(EventLoopMessage), 98 | user_state: user_state, 99 | bot: bot.Bot, 100 | mode: event_handler.Mode(user_state, user_message), 101 | ) 102 | } 103 | 104 | pub type WebsocketMessage(user_message) { 105 | BotMessage(bot.BotMessage) 106 | User(user_message) 107 | } 108 | 109 | fn start_discord_websocket( 110 | mode: event_handler.Mode(user_state, user_message), 111 | event_loop_subject: process.Subject(EventLoopMessage), 112 | host: String, 113 | reconnect: Bool, 114 | session_id: String, 115 | state_ets: booklet.Booklet(dict.Dict(String, String)), 116 | ) -> Result(Nil, actor.StartError) { 117 | let req = 118 | request.new() 119 | |> request.set_host(host) 120 | |> request.set_scheme(http.Https) 121 | |> request.set_path("/?v=10&encoding=json") 122 | |> request.set_header( 123 | "User-Agent", 124 | "DiscordBot (https://github.com/cyteon/discord_gleam, 2.1.0)", 125 | ) 126 | |> request.set_header("Host", "gateway.discord.gg") 127 | |> request.set_header("Connection", "Upgrade") 128 | |> request.set_header("Upgrade", "websocket") 129 | |> request.set_header("Sec-WebSocket-Version", "13") 130 | 131 | logging.log(logging.Debug, "Creating websocket client builder") 132 | 133 | let started = 134 | stratus.new_with_initialiser(request: req, init: fn() { 135 | use selector <- result.try(case event_handler.name_from_mode(mode) { 136 | Ok(name) -> { 137 | process.register(process.self(), name) 138 | |> result.map(fn(_nil) { 139 | process.new_selector() 140 | |> process.select_map(process.named_subject(name), User) 141 | }) 142 | |> result.replace_error( 143 | "failed to register name for websocket client process", 144 | ) 145 | } 146 | Error(_) -> Ok(process.new_selector()) 147 | }) 148 | 149 | let bot_message_subject = process.new_subject() 150 | let bot = 151 | bot.Bot( 152 | ..event_handler.bot_from_mode(mode), 153 | subject: bot_message_subject, 154 | ) 155 | let mode = event_handler.set_bot(mode, bot) 156 | 157 | let selector = 158 | process.select_map(selector, bot_message_subject, BotMessage) 159 | 160 | let #(user_state, selector) = case mode { 161 | event_handler.Normal(on_init:, ..) -> { 162 | let #(user_state, user_selector) = on_init(process.new_selector()) 163 | 164 | let selector = 165 | process.map_selector(user_selector, User) 166 | |> process.merge_selector(selector, _) 167 | 168 | #(user_state, selector) 169 | } 170 | event_handler.Simple(nil_state:, ..) -> { 171 | #(nil_state, selector) 172 | } 173 | } 174 | 175 | let initial_state = 176 | State( 177 | has_received_hello: False, 178 | s: 0, 179 | event_loop_subject:, 180 | user_state:, 181 | bot:, 182 | mode:, 183 | ) 184 | 185 | stratus.initialised(initial_state) 186 | |> stratus.selecting(selector) 187 | |> Ok 188 | }) 189 | |> stratus.on_message(fn(state, msg, conn) { 190 | case msg { 191 | stratus.Text(msg) -> 192 | handle_text_message( 193 | conn, 194 | state, 195 | msg, 196 | state.bot, 197 | state.mode, 198 | reconnect, 199 | session_id, 200 | state_ets, 201 | ) 202 | 203 | stratus.User(BotMessage(bot.SendPacket(packet))) -> { 204 | logging.log(logging.Debug, "User packet: " <> packet) 205 | 206 | let _ = stratus.send_text_message(conn, packet) 207 | 208 | stratus.continue(state) 209 | } 210 | 211 | stratus.User(User(msg)) -> { 212 | let next = 213 | event_handler.handle_event( 214 | state.bot, 215 | state.user_state, 216 | event_handler.InternalUser(msg), 217 | state.mode, 218 | state_ets, 219 | ) 220 | 221 | case next { 222 | event_handler.Continue(user_state, opt) -> { 223 | let new_state = State(..state, user_state:) 224 | let next = stratus.continue(new_state) 225 | 226 | case opt { 227 | option.Some(user_selector) -> 228 | stratus.with_selector( 229 | next, 230 | process.map_selector(user_selector, User), 231 | ) 232 | option.None -> next 233 | } 234 | } 235 | event_handler.Stop -> { 236 | logging.log( 237 | logging.Debug, 238 | "Stopping discord websocket connection", 239 | ) 240 | process.send(state.event_loop_subject, Stop) 241 | stratus.stop() 242 | } 243 | event_handler.StopAbnormal(reason) -> { 244 | logging.log( 245 | logging.Error, 246 | "Stopping discord websocket connection with abnormal reason: " 247 | <> reason, 248 | ) 249 | stratus.stop_abnormal(reason) 250 | } 251 | } 252 | } 253 | 254 | stratus.Binary(_) -> { 255 | logging.log(logging.Debug, "Binary message") 256 | stratus.continue(state) 257 | } 258 | } 259 | }) 260 | |> stratus.on_close(fn(state, close_reason) { 261 | on_close(state, state_ets, close_reason) 262 | }) 263 | |> stratus.start() 264 | 265 | case started { 266 | Error(stratus.ActorFailed(actor_failed)) -> Error(actor_failed) 267 | Error(stratus.HandshakeFailed(_)) -> 268 | Error(actor.InitFailed("handshake failed")) 269 | Error(stratus.FailedToTransferSocket(_)) -> 270 | Error(actor.InitFailed("failed to transfer socket")) 271 | Ok(_) -> Ok(Nil) 272 | } 273 | } 274 | 275 | fn handle_text_message( 276 | conn: stratus.Connection, 277 | state: WebsocketState(user_state, user_message), 278 | msg: String, 279 | bot: bot.Bot, 280 | mode: event_handler.Mode(user_state, user_message), 281 | reconnect: Bool, 282 | session_id: String, 283 | state_ets: booklet.Booklet(dict.Dict(String, String)), 284 | ) { 285 | logging.log(logging.Debug, "Gateway text msg: " <> msg) 286 | 287 | case state.has_received_hello { 288 | False -> { 289 | let identify = case reconnect { 290 | True -> 291 | identify.create_resume_packet( 292 | bot.token, 293 | bot.intents, 294 | session_id, 295 | case dict.get(booklet.get(state_ets), "sequence") { 296 | Ok(s) -> s 297 | Error(_) -> "0" 298 | }, 299 | ) 300 | 301 | False -> identify.create_packet(bot.token, bot.intents) 302 | } 303 | 304 | let _ = stratus.send_text_message(conn, identify) 305 | 306 | let new_state = 307 | State( 308 | has_received_hello: True, 309 | s: 0, 310 | event_loop_subject: state.event_loop_subject, 311 | user_state: state.user_state, 312 | bot: state.bot, 313 | mode: state.mode, 314 | ) 315 | 316 | case hello.string_to_data(msg) { 317 | Ok(data) -> { 318 | process.spawn(fn() { 319 | repeatedly.call(data.d.heartbeat_interval, Nil, fn(_state, _count_) { 320 | let s = case dict.get(booklet.get(state_ets), "sequence") { 321 | Ok(s) -> 322 | case int.parse(s) { 323 | Ok(i) -> i 324 | Error(_) -> 0 325 | } 326 | Error(_) -> 0 327 | } 328 | 329 | let packet = 330 | json.object([ 331 | #("op", json.int(1)), 332 | #("d", case s { 333 | 0 -> json.null() 334 | _ -> json.int(s) 335 | }), 336 | ]) 337 | |> json.to_string() 338 | 339 | logging.log(logging.Debug, "Sending heartbeat: " <> packet) 340 | 341 | let _ = stratus.send_text_message(conn, packet) 342 | 343 | Nil 344 | }) 345 | }) 346 | 347 | Nil 348 | } 349 | 350 | Error(err) -> { 351 | logging.log( 352 | logging.Critical, 353 | "Failed to decode hello packet: " 354 | <> error.json_decode_error_to_string(err), 355 | ) 356 | 357 | let _ = stratus.close(conn, stratus.Normal(<<>>)) 358 | 359 | logging.log(logging.Critical, "Closing websocket due to fatal error") 360 | } 361 | } 362 | 363 | stratus.continue(new_state) 364 | } 365 | 366 | True -> { 367 | let generic_packet = generic.string_to_data(msg) 368 | 369 | case generic_packet.s { 370 | 0 -> Nil 371 | 372 | _ -> { 373 | booklet.update(state_ets, fn(cache) { 374 | dict.insert( 375 | cache, 376 | "sequence", 377 | case dict.get(booklet.get(state_ets), "sequence") { 378 | Ok(s) -> s 379 | Error(_) -> "0" 380 | }, 381 | ) 382 | }) 383 | 384 | Nil 385 | } 386 | } 387 | 388 | case generic_packet.op { 389 | 7 -> { 390 | logging.log(logging.Debug, "Received a reconnect request") 391 | case stratus.close_custom(conn, 4009, <<>>) { 392 | Ok(_) -> logging.log(logging.Debug, "Closed websocket") 393 | Error(_) -> logging.log(logging.Error, "Failed to close websocket") 394 | } 395 | 396 | let host = case 397 | dict.get(booklet.get(state_ets), "resume_gateway_url") 398 | { 399 | Ok(url) -> url 400 | Error(_) -> "gateway.discord.gg" 401 | } 402 | let session_id = case dict.get(booklet.get(state_ets), "session_id") { 403 | Ok(s) -> s 404 | Error(_) -> "" 405 | } 406 | 407 | process.send(state.event_loop_subject, Restart(host:, session_id:)) 408 | } 409 | 410 | _ -> Nil 411 | } 412 | 413 | let new_state = 414 | State( 415 | has_received_hello: True, 416 | s: generic_packet.s, 417 | event_loop_subject: state.event_loop_subject, 418 | user_state: state.user_state, 419 | bot: state.bot, 420 | mode: state.mode, 421 | ) 422 | 423 | let next = 424 | event_handler.handle_event( 425 | bot, 426 | state.user_state, 427 | event_handler.InternalPacket(msg), 428 | mode, 429 | state_ets, 430 | ) 431 | 432 | case next { 433 | event_handler.Continue(user_state, opt) -> { 434 | let new_state = State(..new_state, user_state:) 435 | let next = stratus.continue(new_state) 436 | 437 | case opt { 438 | option.Some(user_selector) -> 439 | stratus.with_selector( 440 | next, 441 | process.map_selector(user_selector, User), 442 | ) 443 | option.None -> next 444 | } 445 | } 446 | event_handler.Stop -> { 447 | logging.log(logging.Debug, "Stopping discord websocket connection") 448 | stratus.stop() 449 | } 450 | event_handler.StopAbnormal(reason) -> { 451 | logging.log( 452 | logging.Error, 453 | "Stopping discord websocket connection with abnormal reason: " 454 | <> reason, 455 | ) 456 | stratus.stop_abnormal(reason) 457 | } 458 | } 459 | } 460 | } 461 | } 462 | 463 | fn on_close( 464 | state: WebsocketState(user_state, user_message), 465 | state_ets: booklet.Booklet(dict.Dict(String, String)), 466 | close_reason: stratus.CloseReason, 467 | ) { 468 | logging.log(logging.Debug, "The webhook was closed") 469 | 470 | case close_reason { 471 | stratus.Custom(custom_close_reason) -> { 472 | case stratus.get_custom_code(custom_close_reason) { 473 | 4000 -> { 474 | logging.log(logging.Error, "Unknown error, not reconnecting") 475 | } 476 | 477 | 4001 -> { 478 | logging.log(logging.Error, "Unknown opcode, not reconnecting") 479 | } 480 | 481 | 4002 -> { 482 | logging.log( 483 | logging.Error, 484 | "Decode error, open a github issue, not reconnecting", 485 | ) 486 | } 487 | 488 | 4003 -> { 489 | logging.log(logging.Error, "Not authenticated, not reconnecting") 490 | } 491 | 492 | 4004 -> { 493 | logging.log( 494 | logging.Error, 495 | "Authentication failed, check your token, not reconnecting", 496 | ) 497 | } 498 | 499 | 4005 -> { 500 | logging.log( 501 | logging.Error, 502 | "Already authenticated, open a github issue, not reconnecting", 503 | ) 504 | } 505 | 506 | 4007 -> { 507 | logging.log(logging.Error, "Invalid sequence, reconnecting") 508 | 509 | let host = case 510 | dict.get(booklet.get(state_ets), "resume_gateway_url") 511 | { 512 | Ok(url) -> url 513 | Error(_) -> "gateway.discord.gg" 514 | } 515 | 516 | process.send(state.event_loop_subject, Restart(host:, session_id: "")) 517 | } 518 | 519 | 4008 -> { 520 | logging.log( 521 | logging.Error, 522 | "You have been ratelimited, not reconnecting", 523 | ) 524 | } 525 | 526 | 4009 -> { 527 | logging.log(logging.Error, "Session timed out, reconnecting") 528 | 529 | let host = case 530 | dict.get(booklet.get(state_ets), "resume_gateway_url") 531 | { 532 | Ok(url) -> url 533 | Error(_) -> "gateway.discord.gg" 534 | } 535 | let session_id = case dict.get(booklet.get(state_ets), "session_id") { 536 | Ok(s) -> s 537 | Error(_) -> "" 538 | } 539 | 540 | process.send(state.event_loop_subject, Restart(host:, session_id:)) 541 | } 542 | 543 | 4010 -> { 544 | logging.log(logging.Error, "Invalid shard, not reconnecting") 545 | } 546 | 547 | 4011 -> { 548 | logging.log(logging.Error, "Sharding required, not reconnecting") 549 | } 550 | 551 | 4012 -> { 552 | logging.log( 553 | logging.Error, 554 | "Invalid API version, open a github issue on the discord_gleam repository, not reconnecting", 555 | ) 556 | } 557 | 558 | 4013 -> { 559 | logging.log( 560 | logging.Error, 561 | "Invalid intents used, open a github issue on the discord_gleam repository, not reconnecting", 562 | ) 563 | } 564 | 565 | 4014 -> { 566 | logging.log( 567 | logging.Error, 568 | "Disallowed intents used, did you remember to enable any priveleged intents you used in the Discord Developer Portal (https://discord.dev)? Not reconnecting", 569 | ) 570 | } 571 | 572 | _ -> { 573 | logging.log(logging.Error, "Unknown close code, not reconnecting") 574 | } 575 | } 576 | } 577 | _ -> Nil 578 | } 579 | } 580 | -------------------------------------------------------------------------------- /test/example_bot.gleam: -------------------------------------------------------------------------------- 1 | import booklet 2 | import discord_gleam 3 | import discord_gleam/discord/intents 4 | import discord_gleam/event_handler 5 | import discord_gleam/types/bot 6 | import discord_gleam/types/guild 7 | import discord_gleam/types/message 8 | import discord_gleam/types/slash_command 9 | import discord_gleam/ws/commands/request_guild_members 10 | import discord_gleam/ws/packets/interaction_create 11 | import gleam/bool 12 | import gleam/dict 13 | import gleam/erlang/process 14 | import gleam/float 15 | import gleam/int 16 | import gleam/list 17 | import gleam/option.{None, Some} 18 | import gleam/otp/static_supervisor as supervisor 19 | import gleam/otp/supervision 20 | import gleam/string 21 | import logging 22 | 23 | pub fn main(token: String, client_id: String, guild_id: String) { 24 | logging.configure() 25 | logging.set_level(logging.Debug) 26 | 27 | let bot = discord_gleam.bot(token, client_id, intents.all()) 28 | 29 | let test_cmd = 30 | slash_command.SlashCommand( 31 | name: "test", 32 | description: "Test command", 33 | options: [ 34 | slash_command.CommandOption( 35 | name: "string", 36 | description: "Test option", 37 | type_: slash_command.StringOption, 38 | required: False, 39 | ), 40 | slash_command.CommandOption( 41 | name: "int", 42 | description: "Test option", 43 | type_: slash_command.IntOption, 44 | required: False, 45 | ), 46 | ], 47 | ) 48 | 49 | let test_cmd2 = 50 | slash_command.SlashCommand( 51 | name: "test2", 52 | description: "Test command", 53 | options: [ 54 | slash_command.CommandOption( 55 | name: "bool", 56 | description: "Test option", 57 | type_: slash_command.BoolOption, 58 | required: False, 59 | ), 60 | slash_command.CommandOption( 61 | name: "float", 62 | description: "Test option", 63 | type_: slash_command.FloatOption, 64 | required: False, 65 | ), 66 | ], 67 | ) 68 | 69 | let _ = discord_gleam.wipe_global_commands(bot) 70 | let _ = discord_gleam.register_global_commands(bot, [test_cmd]) 71 | 72 | let _ = discord_gleam.wipe_guild_commands(bot, guild_id) 73 | let _ = discord_gleam.register_guild_commands(bot, guild_id, [test_cmd2]) 74 | 75 | // SIMPLE BOT EXAMPLE 76 | // let bot = 77 | // supervision.worker(fn() { 78 | // discord_gleam.simple(bot, [simple_handler]) 79 | // |> discord_gleam.start() 80 | // }) 81 | 82 | // ADVANCED BOT EXAMPLE 83 | let name = process.new_name("user_message_subject") 84 | let bot = 85 | supervision.worker(fn() { 86 | discord_gleam.new( 87 | bot, 88 | fn(selector) { 89 | let subject = process.new_subject() 90 | 91 | process.send_after( 92 | process.named_subject(name), 93 | 1000, 94 | "named subject message", 95 | ) 96 | 97 | #(subject, process.select(selector, subject)) 98 | }, 99 | fn(bot, state, msg) { normal_handler(bot, state, name, msg) }, 100 | ) 101 | |> discord_gleam.with_name(name) 102 | |> discord_gleam.start() 103 | }) 104 | 105 | let assert Ok(_) = 106 | supervisor.new(supervisor.OneForOne) 107 | |> supervisor.add(bot) 108 | |> supervisor.start() 109 | 110 | process.sleep_forever() 111 | } 112 | 113 | fn simple_handler(bot: bot.Bot, packet: event_handler.Packet) { 114 | case packet { 115 | event_handler.ReadyPacket(ready) -> { 116 | logging.log( 117 | logging.Info, 118 | "Logged in as " 119 | <> ready.d.user.username 120 | <> "#" 121 | <> ready.d.user.discriminator, 122 | ) 123 | 124 | list.each(ready.d.guilds, fn(guild) { 125 | let assert guild.UnavailableGuild(id, ..) = guild 126 | logging.log(logging.Info, "Unavailable guild: " <> id) 127 | 128 | discord_gleam.request_guild_members( 129 | bot, 130 | guild_id: id, 131 | option: request_guild_members.Query("", option.None), 132 | presences: option.Some(True), 133 | nonce: option.Some("test_request"), 134 | ) 135 | }) 136 | 137 | Nil 138 | } 139 | 140 | event_handler.MessageUpdatePacket(message_update) -> { 141 | logging.log( 142 | logging.Info, 143 | "Message edited, new content: " <> message_update.d.content, 144 | ) 145 | } 146 | 147 | event_handler.GuildBanAddPacket(ban) -> { 148 | logging.log( 149 | logging.Info, 150 | "User banned: " 151 | <> ban.d.user.username 152 | <> " (ID: " 153 | <> ban.d.user.id 154 | <> ")", 155 | ) 156 | } 157 | 158 | event_handler.GuildBanRemovePacket(ban) -> { 159 | logging.log( 160 | logging.Info, 161 | "User unbanned: " 162 | <> ban.d.user.username 163 | <> " (ID: " 164 | <> ban.d.user.id 165 | <> ")", 166 | ) 167 | } 168 | 169 | event_handler.GuildRoleCreatePacket(role) -> { 170 | logging.log( 171 | logging.Info, 172 | "Role created: " 173 | <> role.d.role.name 174 | <> " (ID: " 175 | <> role.d.role.id 176 | <> ")", 177 | ) 178 | 179 | Nil 180 | } 181 | 182 | event_handler.GuildRoleUpdatePacket(role) -> { 183 | logging.log( 184 | logging.Info, 185 | "Role updated: " 186 | <> role.d.role.name 187 | <> " (ID: " 188 | <> role.d.role.id 189 | <> ")", 190 | ) 191 | 192 | Nil 193 | } 194 | 195 | event_handler.GuildRoleDeletePacket(role) -> { 196 | logging.log(logging.Info, "Role deleted: " <> role.d.role_id) 197 | 198 | Nil 199 | } 200 | 201 | event_handler.GuildMemberAddPacket(member_add) -> { 202 | logging.log( 203 | logging.Info, 204 | "Member added: " 205 | <> member_add.d.guild_member.user.username 206 | <> " (ID: " 207 | <> member_add.d.guild_member.user.id 208 | <> ")", 209 | ) 210 | } 211 | 212 | event_handler.GuildMemberRemovePacket(member_remove) -> { 213 | logging.log( 214 | logging.Info, 215 | "Member removed: " 216 | <> member_remove.d.user.username 217 | <> " (ID: " 218 | <> member_remove.d.user.id 219 | <> ")", 220 | ) 221 | } 222 | 223 | event_handler.GuildMemberUpdatePacket(member_update) -> { 224 | logging.log( 225 | logging.Info, 226 | "Member updated: " 227 | <> member_update.d.guild_member.user.username 228 | <> " (ID: " 229 | <> member_update.d.guild_member.user.id 230 | <> ")", 231 | ) 232 | } 233 | 234 | event_handler.GuildMembersChunkPacket(chunk) -> { 235 | logging.log( 236 | logging.Info, 237 | "Guild members chunk received: " <> chunk.d.guild_id, 238 | ) 239 | } 240 | 241 | event_handler.ChannelCreatePacket(channel) -> { 242 | case channel.d.guild_id { 243 | // only create if channel in guild 244 | // aka not on DM channel create 245 | Some(_) -> { 246 | logging.log( 247 | logging.Info, 248 | "Channel created: " 249 | <> case channel.d.name { 250 | Some(name) -> name 251 | None -> "No name" 252 | } 253 | <> " (ID: " 254 | <> channel.d.id 255 | <> ")", 256 | ) 257 | 258 | let _ = 259 | discord_gleam.send_message( 260 | bot, 261 | channel.d.id, 262 | "Channel created: " 263 | <> case channel.d.name { 264 | Some(name) -> name 265 | None -> "No name" 266 | } 267 | <> "\nID: " 268 | <> channel.d.id 269 | <> "\nParent ID: " 270 | <> case channel.d.parent_id { 271 | Some(id) -> id 272 | None -> "None" 273 | }, 274 | [], 275 | ) 276 | 277 | Nil 278 | } 279 | 280 | None -> { 281 | logging.log(logging.Info, "DM channel created: " <> channel.d.id) 282 | 283 | let _ = 284 | discord_gleam.send_message( 285 | bot, 286 | channel.d.id, 287 | "DM channel created: " <> channel.d.id, 288 | [], 289 | ) 290 | 291 | Nil 292 | } 293 | } 294 | } 295 | 296 | event_handler.ChannelDeletePacket(channel) -> { 297 | logging.log( 298 | logging.Info, 299 | "Channel deleted: " 300 | <> case channel.d.name { 301 | Some(name) -> name 302 | None -> "No name" 303 | } 304 | <> " (ID: " 305 | <> channel.d.id 306 | <> ")", 307 | ) 308 | } 309 | 310 | event_handler.ChannelUpdatePacket(channel) -> { 311 | logging.log( 312 | logging.Info, 313 | "Channel updated: " 314 | <> case channel.d.name { 315 | Some(name) -> name 316 | None -> "No name" 317 | } 318 | <> " (ID: " 319 | <> channel.d.id 320 | <> ")", 321 | ) 322 | } 323 | 324 | event_handler.MessagePacket(message) -> { 325 | case message.d.author.id != bot.client_id { 326 | True -> { 327 | logging.log(logging.Info, "Got message: " <> message.d.content) 328 | 329 | case message.d.content { 330 | "!ping" -> { 331 | let _ = 332 | discord_gleam.send_message( 333 | bot, 334 | message.d.channel_id, 335 | "Pong!", 336 | [], 337 | ) 338 | 339 | Nil 340 | } 341 | 342 | "!edit" -> { 343 | let msg = 344 | discord_gleam.send_message( 345 | bot, 346 | message.d.channel_id, 347 | "This message will be edited in 5 seconds!", 348 | [], 349 | ) 350 | 351 | case msg { 352 | Ok(msg) -> { 353 | process.sleep(5000) 354 | 355 | let _ = 356 | discord_gleam.edit_message( 357 | bot, 358 | message.d.channel_id, 359 | msg.id, 360 | "This message has been edited!", 361 | [], 362 | ) 363 | 364 | Nil 365 | } 366 | 367 | Error(err) -> { 368 | let _ = 369 | discord_gleam.send_message( 370 | bot, 371 | message.d.channel_id, 372 | "Failed to send message!", 373 | [], 374 | ) 375 | 376 | echo err 377 | 378 | Nil 379 | } 380 | } 381 | 382 | Nil 383 | } 384 | 385 | "!dm_channel" -> { 386 | let res = 387 | discord_gleam.create_dm_channel(bot, message.d.author.id) 388 | 389 | let _ = echo res 390 | 391 | case res { 392 | Ok(channel) -> { 393 | let _ = 394 | discord_gleam.send_message( 395 | bot, 396 | message.d.channel_id, 397 | "ID: " 398 | <> channel.id 399 | <> "\nLast message ID: " 400 | <> case channel.last_message_id { 401 | Some(id) -> id 402 | None -> "None" 403 | }, 404 | [], 405 | ) 406 | 407 | Nil 408 | } 409 | 410 | Error(err) -> { 411 | let _ = 412 | discord_gleam.send_message( 413 | bot, 414 | message.d.channel_id, 415 | "Failed to create DM channel!", 416 | [], 417 | ) 418 | 419 | echo err 420 | 421 | Nil 422 | } 423 | } 424 | } 425 | 426 | "!dm" -> { 427 | let res = 428 | discord_gleam.send_direct_message( 429 | bot, 430 | message.d.author.id, 431 | "DM!", 432 | [], 433 | ) 434 | 435 | case res { 436 | Ok(_) -> { 437 | let _ = 438 | discord_gleam.send_message( 439 | bot, 440 | message.d.channel_id, 441 | "DM sent!", 442 | [], 443 | ) 444 | 445 | Nil 446 | } 447 | 448 | Error(err) -> { 449 | let _ = 450 | discord_gleam.send_message( 451 | bot, 452 | message.d.channel_id, 453 | "Failed to send DM!", 454 | [], 455 | ) 456 | 457 | echo err 458 | 459 | Nil 460 | } 461 | } 462 | } 463 | 464 | "!embed" -> { 465 | let embed1 = 466 | message.Embed( 467 | title: "Embed Title", 468 | description: "Embed Description", 469 | color: 0x00FF00, 470 | ) 471 | 472 | let _ = 473 | discord_gleam.send_message(bot, message.d.channel_id, "Embed!", [ 474 | embed1, 475 | ]) 476 | 477 | Nil 478 | } 479 | 480 | "!reply" -> { 481 | let _ = 482 | discord_gleam.reply( 483 | bot, 484 | message.d.channel_id, 485 | message.d.id, 486 | "Reply!", 487 | [], 488 | ) 489 | 490 | Nil 491 | } 492 | 493 | "hello" -> { 494 | let _ = 495 | discord_gleam.reply( 496 | bot, 497 | message.d.channel_id, 498 | message.d.id, 499 | "hello", 500 | [], 501 | ) 502 | 503 | Nil 504 | } 505 | 506 | _ -> Nil 507 | } 508 | } 509 | 510 | False -> Nil 511 | } 512 | 513 | case message.d.content, message.d.guild_id { 514 | "!kick " <> args, Some(guild_id) -> { 515 | let args = string.split(args, " ") 516 | let #(user, args) = case args { 517 | [user, ..args] -> #(user, args) 518 | _ -> #("", []) 519 | } 520 | 521 | let user = string.replace(user, "<@", "") 522 | let user = string.replace(user, ">", "") 523 | 524 | let reason = string.join(args, " ") 525 | 526 | let resp = discord_gleam.kick_member(bot, guild_id, user, reason) 527 | 528 | case resp { 529 | Ok(_) -> { 530 | let _ = 531 | discord_gleam.send_message( 532 | bot, 533 | message.d.channel_id, 534 | "Kicked user!", 535 | [], 536 | ) 537 | 538 | Nil 539 | } 540 | 541 | Error(_) -> { 542 | let _ = 543 | discord_gleam.send_message( 544 | bot, 545 | message.d.channel_id, 546 | "Failed to kick user!", 547 | [], 548 | ) 549 | 550 | Nil 551 | } 552 | } 553 | } 554 | 555 | _, _ -> Nil 556 | } 557 | 558 | case message.d.content, message.d.guild_id { 559 | "!ban " <> args, Some(guild_id) -> { 560 | let args = string.split(args, " ") 561 | let #(user, args) = case args { 562 | [user, ..args] -> #(user, args) 563 | _ -> #("", []) 564 | } 565 | 566 | let user = string.replace(user, "<@", "") 567 | let user = string.replace(user, ">", "") 568 | 569 | let reason = string.join(args, " ") 570 | 571 | let resp = discord_gleam.ban_member(bot, guild_id, user, reason) 572 | 573 | case resp { 574 | Ok(_) -> { 575 | let _ = 576 | discord_gleam.send_message( 577 | bot, 578 | message.d.channel_id, 579 | "Banned user!", 580 | [], 581 | ) 582 | 583 | Nil 584 | } 585 | 586 | Error(_) -> { 587 | let _ = 588 | discord_gleam.send_message( 589 | bot, 590 | message.d.channel_id, 591 | "Failed to ban user!", 592 | [], 593 | ) 594 | 595 | Nil 596 | } 597 | } 598 | } 599 | 600 | _, _ -> Nil 601 | } 602 | } 603 | 604 | event_handler.MessageDeletePacket(deleted) -> { 605 | logging.log(logging.Info, "Deleted message: " <> deleted.d.id) 606 | 607 | let msg = dict.get(booklet.get(bot.cache.messages), deleted.d.id) 608 | 609 | case msg { 610 | Ok(msg) -> { 611 | logging.log(logging.Info, "Message content: " <> msg.content) 612 | } 613 | Error(_) -> { 614 | logging.log(logging.Info, "Deleted message not found") 615 | } 616 | } 617 | 618 | Nil 619 | } 620 | 621 | event_handler.MessageDeleteBulkPacket(deleted_bulk) -> { 622 | logging.log( 623 | logging.Info, 624 | "Bulk deleted messages: " 625 | <> list.fold(deleted_bulk.d.ids, "", fn(acc, id) { acc <> id <> ", " }), 626 | ) 627 | } 628 | 629 | event_handler.InteractionCreatePacket(interaction) -> { 630 | logging.log(logging.Info, "Interaction: " <> interaction.d.data.name) 631 | 632 | case interaction.d.data.name { 633 | "test" -> { 634 | let _ = case interaction.d.data.options { 635 | Some(options) -> { 636 | let value = case list.first(options) { 637 | Ok(option) -> 638 | case option.value { 639 | interaction_create.StringValue(value) -> value 640 | interaction_create.IntValue(value) -> int.to_string(value) 641 | interaction_create.BoolValue(value) -> bool.to_string(value) 642 | interaction_create.FloatValue(value) -> 643 | float.to_string(value) 644 | } 645 | 646 | Error(_) -> "No value" 647 | } 648 | 649 | let _ = 650 | discord_gleam.interaction_reply_message( 651 | interaction, 652 | "test: " <> value, 653 | True, 654 | // ephemeral 655 | ) 656 | } 657 | 658 | None -> { 659 | let _ = 660 | discord_gleam.interaction_reply_message( 661 | interaction, 662 | "test: No options", 663 | True, 664 | ) 665 | } 666 | } 667 | 668 | Nil 669 | } 670 | 671 | "test2" -> { 672 | let _ = case interaction.d.data.options { 673 | Some(options) -> { 674 | let value = case list.last(options) { 675 | Ok(option) -> 676 | case option.value { 677 | interaction_create.StringValue(value) -> value 678 | interaction_create.IntValue(value) -> int.to_string(value) 679 | interaction_create.BoolValue(value) -> bool.to_string(value) 680 | interaction_create.FloatValue(value) -> 681 | float.to_string(value) 682 | } 683 | 684 | Error(_) -> "No value" 685 | } 686 | 687 | let _ = 688 | discord_gleam.interaction_reply_message( 689 | interaction, 690 | "test2: " <> value, 691 | False, 692 | ) 693 | } 694 | 695 | None -> { 696 | let _ = 697 | discord_gleam.interaction_reply_message( 698 | interaction, 699 | "test2: No options", 700 | False, 701 | ) 702 | } 703 | } 704 | 705 | Nil 706 | } 707 | 708 | _ -> Nil 709 | } 710 | } 711 | 712 | event_handler.PresenceUpdatePacket(presence) -> { 713 | logging.log(logging.Info, "Presence updated for: " <> presence.d.user.id) 714 | } 715 | 716 | _ -> Nil 717 | } 718 | } 719 | 720 | fn normal_handler( 721 | bot: bot.Bot, 722 | state: process.Subject(String), 723 | name: process.Name(String), 724 | msg: discord_gleam.HandlerMessage(String), 725 | ) { 726 | case msg { 727 | discord_gleam.Packet(packet) -> { 728 | case packet { 729 | event_handler.ReadyPacket(ready) -> { 730 | logging.log( 731 | logging.Info, 732 | "Logged in as " 733 | <> ready.d.user.username 734 | <> "#" 735 | <> ready.d.user.discriminator, 736 | ) 737 | 738 | list.each(ready.d.guilds, fn(guild) { 739 | let assert guild.UnavailableGuild(id, ..) = guild 740 | logging.log(logging.Info, "Unavailable guild: " <> id) 741 | 742 | discord_gleam.request_guild_members( 743 | bot, 744 | guild_id: id, 745 | option: request_guild_members.Query("", option.None), 746 | presences: option.Some(True), 747 | nonce: option.Some("test_request"), 748 | ) 749 | }) 750 | 751 | discord_gleam.continue(state) 752 | } 753 | 754 | event_handler.MessagePacket(message) -> { 755 | logging.log(logging.Info, "Got message: " <> message.d.content) 756 | 757 | case message.d.content { 758 | "!ping" -> { 759 | let _ = 760 | discord_gleam.send_message( 761 | bot, 762 | message.d.channel_id, 763 | "Pong!", 764 | [], 765 | ) 766 | 767 | discord_gleam.continue(state) 768 | } 769 | "!send " <> message -> { 770 | process.send(state, message) 771 | 772 | discord_gleam.continue(state) 773 | } 774 | "!send_to_name " <> message -> { 775 | process.send(process.named_subject(name), message) 776 | 777 | discord_gleam.continue(state) 778 | } 779 | "!stop" -> { 780 | discord_gleam.stop() 781 | } 782 | "!stop_abnormal" -> { 783 | discord_gleam.stop_abnormal("testing what will happen") 784 | } 785 | _ -> discord_gleam.continue(state) 786 | } 787 | } 788 | _ -> discord_gleam.continue(state) 789 | } 790 | } 791 | 792 | discord_gleam.User(msg) -> { 793 | logging.log(logging.Info, "Got user message from subject: " <> msg) 794 | discord_gleam.continue(state) 795 | } 796 | } 797 | } 798 | --------------------------------------------------------------------------------