├── oauth ├── bun.lockb ├── package.json ├── README.md ├── index.ts ├── tsconfig.json └── .gitignore ├── .gitignore ├── src ├── glitch │ ├── types │ │ ├── cheer.gleam │ │ ├── cooldown.gleam │ │ ├── max_per_stream.gleam │ │ ├── badge.gleam │ │ ├── cheermote.gleam │ │ ├── image.gleam │ │ ├── grant.gleam │ │ ├── emote.gleam │ │ ├── reward.gleam │ │ ├── condition.gleam │ │ ├── transport.gleam │ │ ├── event.gleam │ │ ├── message.gleam │ │ ├── access_token.gleam │ │ ├── scope.gleam │ │ └── subscription.gleam │ ├── extended │ │ ├── function_ext.gleam │ │ ├── json_ext.gleam │ │ ├── uri_ext.gleam │ │ ├── request_ext.gleam │ │ └── dynamic_ext.gleam │ ├── api │ │ ├── api.gleam │ │ ├── client.gleam │ │ ├── chat.gleam │ │ ├── eventsub.gleam │ │ ├── user.gleam │ │ ├── api_response.gleam │ │ ├── api_request.gleam │ │ └── auth.gleam │ ├── error.gleam │ ├── eventsub │ │ ├── websocket_server.gleam │ │ ├── client.gleam │ │ └── websocket_message.gleam │ └── auth │ │ ├── redirect_server.gleam │ │ ├── token_fetcher.gleam │ │ └── auth_provider.gleam └── glitch.gleam ├── test └── glitch_test.gleam ├── .github └── workflows │ └── test.yml ├── README.md ├── flake.nix ├── gleam.toml ├── flake.lock └── manifest.toml /oauth/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmmulroy/glitch/HEAD/oauth/bun.lockb -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | /build 4 | erl_crash.dump 5 | .envrc 6 | .direnv/ 7 | -------------------------------------------------------------------------------- /src/glitch/types/cheer.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | 3 | pub type Cheer { 4 | Cheer(bits: Int) 5 | } 6 | 7 | pub fn decoder() -> Decoder(Cheer) { 8 | dynamic.decode1(Cheer, dynamic.field("bits", dynamic.int)) 9 | } 10 | -------------------------------------------------------------------------------- /test/glitch_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | // gleeunit test functions end in `_test` 9 | pub fn hello_world_test() { 10 | 1 11 | |> should.equal(1) 12 | } 13 | -------------------------------------------------------------------------------- /oauth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth", 3 | "module": "index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5.0.0" 10 | }, 11 | "dependencies": { 12 | "elysia": "^1.0.5" 13 | } 14 | } -------------------------------------------------------------------------------- /oauth/README.md: -------------------------------------------------------------------------------- 1 | # oauth 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.0.33. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /src/glitch/extended/function_ext.gleam: -------------------------------------------------------------------------------- 1 | pub fn compose(fun1: fn(a) -> b, fun2: fn(b) -> c) -> fn(a) -> c { 2 | fn(a) { fun2(fun1(a)) } 3 | } 4 | 5 | pub fn constant(value: value) -> fn(anything) -> value { 6 | fn(_) { value } 7 | } 8 | 9 | pub fn ignore(_value: value) -> Nil { 10 | Nil 11 | } 12 | -------------------------------------------------------------------------------- /src/glitch/types/cooldown.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | 3 | pub type Cooldown { 4 | Cooldown(is_enabled: Bool, seconds: Int) 5 | } 6 | 7 | pub fn cooldown_decoder() -> Decoder(Cooldown) { 8 | dynamic.decode2( 9 | Cooldown, 10 | dynamic.field("is_enabled", dynamic.bool), 11 | dynamic.field("second", dynamic.int), 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/glitch/types/max_per_stream.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | 3 | pub type MaxPerStream { 4 | MaxPerStream(is_enabled: Bool, value: Int) 5 | } 6 | 7 | pub fn max_per_stream_decoder() -> Decoder(MaxPerStream) { 8 | dynamic.decode2( 9 | MaxPerStream, 10 | dynamic.field("is_enabled", dynamic.bool), 11 | dynamic.field("value", dynamic.int), 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/glitch/types/badge.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | 3 | pub type Badge { 4 | Badge(set_id: String, id: String, info: String) 5 | } 6 | 7 | pub fn decoder() -> Decoder(Badge) { 8 | dynamic.decode3( 9 | Badge, 10 | dynamic.field("set_id", dynamic.string), 11 | dynamic.field("id", dynamic.string), 12 | dynamic.field("info", dynamic.string), 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/glitch/types/cheermote.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | 3 | pub type Cheermote { 4 | Cheermote(prefix: String, bits: Int, tier: Int) 5 | } 6 | 7 | pub fn decoder() -> Decoder(Cheermote) { 8 | dynamic.decode3( 9 | Cheermote, 10 | dynamic.field("prefix", dynamic.string), 11 | dynamic.field("bits", dynamic.int), 12 | dynamic.field("tier", dynamic.int), 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/glitch/types/image.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/uri.{type Uri} 3 | import glitch/extended/dynamic_ext 4 | 5 | pub type Image { 6 | Image(url_1x: Uri, url_2x: Uri, url_3x: Uri) 7 | } 8 | 9 | pub fn image_decoder() -> Decoder(Image) { 10 | dynamic.decode3( 11 | Image, 12 | dynamic.field("url_1x", dynamic_ext.uri), 13 | dynamic.field("url_2x", dynamic_ext.uri), 14 | dynamic.field("url_3x", dynamic_ext.uri), 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/glitch/api/api.gleam: -------------------------------------------------------------------------------- 1 | import gleam/httpc 2 | import gleam/result 3 | import glitch/api/api_request.{type TwitchApiRequest} 4 | import glitch/api/api_response.{type TwitchApiResponse} 5 | import glitch/error.{type TwitchError, RequestError} 6 | 7 | pub fn send( 8 | request: TwitchApiRequest, 9 | ) -> Result(TwitchApiResponse(String), TwitchError) { 10 | request 11 | |> api_request.to_http_request 12 | |> httpc.send 13 | |> result.map(api_response.of_http_response) 14 | |> result.map_error(RequestError) 15 | } 16 | -------------------------------------------------------------------------------- /src/glitch/extended/json_ext.gleam: -------------------------------------------------------------------------------- 1 | import gleam/json.{type DecodeError, type Json} 2 | import gleam/option.{type Option} 3 | import gleam/uri.{type Uri} 4 | 5 | pub type JsonDecoder(input, output) = 6 | fn(input) -> Result(output, DecodeError) 7 | 8 | pub type JsonEncoder(input) = 9 | fn(input) -> Json 10 | 11 | pub fn option(from opt: Option(a), using encoder: JsonEncoder(a)) -> Json { 12 | json.nullable(opt, encoder) 13 | } 14 | 15 | pub fn uri(uri: Uri) -> Json { 16 | uri 17 | |> uri.to_string 18 | |> json.string 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "1.0.0" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /oauth/index.ts: -------------------------------------------------------------------------------- 1 | import Elysia from "elysia"; 2 | 3 | const app = new Elysia() 4 | .get("/twitch/oauth", async (ctx) => { 5 | console.log(JSON.stringify(ctx.query)); 6 | }) 7 | .listen(3030); 8 | 9 | console.log( 10 | `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, 11 | ); 12 | 13 | const url = ` 14 | https://id.twitch.tv/oauth2/authorize 15 | ?response_type=code 16 | &client_id=cew8p1bv247ua1czt6a1okon8ejy1r 17 | &redirect_uri=http://localhost:3030/twitch/oauth 18 | &scope=user%3Awrite%3Achat+user%3Abot+channel%3Abot 19 | &state=foobar 20 | `; 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # glitch 2 | 3 | [![Package Version](https://img.shields.io/hexpm/v/glitch)](https://hex.pm/packages/glitch) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/glitch/) 5 | 6 | ```sh 7 | gleam add glitch 8 | ``` 9 | ```gleam 10 | import glitch 11 | 12 | pub fn main() { 13 | // TODO: An example of the project in use 14 | } 15 | ``` 16 | 17 | Further documentation can be found at . 18 | 19 | ## Development 20 | 21 | ```sh 22 | gleam run # Run the project 23 | gleam test # Run the tests 24 | gleam shell # Run an Erlang shell 25 | ``` 26 | -------------------------------------------------------------------------------- /src/glitch/error.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Dynamic} 2 | import gleam/json.{type DecodeError} 3 | 4 | // TODO: Write a pretty printer for errors 5 | 6 | pub type TwitchError { 7 | AuthError(AuthError) 8 | EventSubError(EventSubError) 9 | InvalidResponseType(expected: String, received: String) 10 | RequestError(Dynamic) 11 | ResponseDecodeError(DecodeError) 12 | ResponseError(status: Int, message: String) 13 | } 14 | 15 | pub type AuthError { 16 | AccessTokenExpired 17 | InvalidGetTokenRequest 18 | InvalidAccessToken 19 | TokenFetcherFetchError(cause: TwitchError) 20 | TokenFetcherStartError 21 | ValidateTokenError(cause: TwitchError) 22 | } 23 | 24 | pub type EventSubError { 25 | EventSubStartError 26 | } 27 | -------------------------------------------------------------------------------- /oauth/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Description for the project"; 3 | 4 | inputs = { 5 | flake-parts.url = "github:hercules-ci/flake-parts"; 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 7 | nixpkgs_master.url = "github:NixOS/nixpkgs?ref=master"; 8 | }; 9 | 10 | outputs = inputs@{ flake-parts, ... }: 11 | flake-parts.lib.mkFlake { inherit inputs; } { 12 | imports = []; 13 | systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; 14 | perSystem = { config, self', inputs', pkgs, system, ... }: 15 | { 16 | devShells = { 17 | default = pkgs.mkShell { 18 | buildInputs = with pkgs; [gleam erlang_27 rebar3 bun]; 19 | }; 20 | }; 21 | }; 22 | flake = {}; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/glitch/types/grant.gleam: -------------------------------------------------------------------------------- 1 | pub type GrantType { 2 | AuthorizationCode 3 | ClientCredentials 4 | RefreshToken 5 | DeviceCode 6 | Implicit 7 | } 8 | 9 | pub fn to_string(grant_type: GrantType) -> String { 10 | case grant_type { 11 | AuthorizationCode -> "authorization_code" 12 | ClientCredentials -> "client_credentials" 13 | RefreshToken -> "refresh_token" 14 | DeviceCode -> "device_code" 15 | Implicit -> "implicit" 16 | } 17 | } 18 | 19 | pub fn from_string(str: String) -> Result(GrantType, Nil) { 20 | case str { 21 | "authorization_code" -> Ok(AuthorizationCode) 22 | "client_credentials" -> Ok(ClientCredentials) 23 | "refresh_token" -> Ok(RefreshToken) 24 | "device_code" -> Ok(DeviceCode) 25 | "implicit" -> Ok(Implicit) 26 | _ -> Error(Nil) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "glitch" 2 | version = "0.0.6" 3 | gleam = ">= 0.32.0" 4 | 5 | # Fill out these fields if you intend to generate HTML documentation or publish 6 | # your project to the Hex package manager. 7 | # 8 | description = "A Twitch client for Gleam" 9 | licences = ["MIT"] 10 | repository = { type = "github", user = "dmmulroy", repo = "glitch" } 11 | # links = [{ title = "Website", href = "https://gleam.run" }] 12 | # 13 | # For a full reference of all the available options, you can have a look at 14 | # https://gleam.run/writing-gleam/gleam-toml/. 15 | 16 | [dependencies] 17 | gleam_stdlib = "~> 0.40.0" 18 | logging = "~> 1.0" 19 | gleam_json = "~> 2.0" 20 | gleam_httpc = "~> 2.2" 21 | gleam_http = "~> 3.6" 22 | dot_env = "~> 0.2" 23 | gleam_otp = "~> 0.11" 24 | mist = "~> 2.0" 25 | gleam_erlang = "~> 0.25" 26 | shellout = "~> 1.6" 27 | prng = "~> 3.0" 28 | stratus = "~> 0.9" 29 | 30 | [dev-dependencies] 31 | gleeunit = "~> 1.2" 32 | -------------------------------------------------------------------------------- /src/glitch/types/emote.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/result 3 | 4 | pub type Emote { 5 | Emote(id: String, emote_set_id: String, owner_id: String, format: Format) 6 | } 7 | 8 | pub fn decoder() -> Decoder(Emote) { 9 | dynamic.decode4( 10 | Emote, 11 | dynamic.field("id", dynamic.string), 12 | dynamic.field("emote_set_id", dynamic.string), 13 | dynamic.field("owner_id", dynamic.string), 14 | dynamic.field("format", format_decoder()), 15 | ) 16 | } 17 | 18 | pub type Format { 19 | Animated 20 | Static 21 | } 22 | 23 | pub fn format_to_string(format: Format) -> String { 24 | case format { 25 | Animated -> "animated" 26 | Static -> "static" 27 | } 28 | } 29 | 30 | pub fn format_from_string(string: String) -> Result(Format, Nil) { 31 | case string { 32 | "animated" -> Ok(Animated) 33 | "static" -> Ok(Static) 34 | _ -> Error(Nil) 35 | } 36 | } 37 | 38 | pub fn format_decoder() -> Decoder(Format) { 39 | fn(data: dynamic.Dynamic) { 40 | use string <- result.try(dynamic.string(data)) 41 | 42 | string 43 | |> format_from_string 44 | |> result.replace_error([ 45 | dynamic.DecodeError( 46 | expected: "Format", 47 | found: "String(" <> string <> ")", 48 | path: [], 49 | ), 50 | ]) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/glitch/extended/uri_ext.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http.{type Scheme} 2 | import gleam/list 3 | import gleam/option.{None, Some} 4 | import gleam/string 5 | import gleam/uri.{type Uri, Uri} 6 | 7 | const empty = Uri( 8 | scheme: None, 9 | userinfo: None, 10 | host: None, 11 | port: None, 12 | path: "", 13 | query: None, 14 | fragment: None, 15 | ) 16 | 17 | pub fn new() -> Uri { 18 | empty 19 | } 20 | 21 | pub fn path_from_segments(segments: List(String)) -> Result(Uri, Nil) { 22 | segments 23 | |> list.map(string.replace(in: _, each: "/", with: "")) 24 | |> string.join(with: "/") 25 | |> uri.parse 26 | } 27 | 28 | pub fn host_from_string(host_str: String) -> Result(Uri, Nil) { 29 | uri.parse(host_str) 30 | } 31 | 32 | pub fn set_host(uri: Uri, host: Uri) -> Uri { 33 | Uri(..uri, host: host.host) 34 | } 35 | 36 | pub fn set_scheme(uri: Uri, scheme: Scheme) -> Uri { 37 | Uri(..uri, scheme: Some(http.scheme_to_string(scheme))) 38 | } 39 | 40 | pub fn set_path(uri: Uri, path: Uri) -> Uri { 41 | Uri(..uri, path: path.path) 42 | } 43 | 44 | pub fn set_port(uri: Uri, port: Int) -> Uri { 45 | Uri(..uri, port: Some(port)) 46 | } 47 | 48 | pub fn set_query(uri: Uri, query_params: List(#(String, String))) -> Uri { 49 | let query = 50 | query_params 51 | |> uri.query_to_string 52 | |> Some 53 | 54 | Uri(..uri, query: query) 55 | } 56 | -------------------------------------------------------------------------------- /src/glitch/extended/request_ext.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http.{type Header} 2 | import gleam/http/request.{type Request, Request} 3 | import gleam/list 4 | import gleam/option.{type Option} 5 | import gleam/string 6 | 7 | pub fn merge_headers( 8 | request: Request(data), 9 | into base: List(Header), 10 | from overrides: List(Header), 11 | ) -> Request(data) { 12 | request 13 | |> set_headers(base) 14 | |> set_headers(overrides) 15 | } 16 | 17 | /// Set a request's headers using a list. 18 | /// 19 | /// Similar to `set_header` but for setting more than a single header at once. 20 | /// Existing headers on the request will be replaced. 21 | pub fn set_headers( 22 | request: Request(body), 23 | headers: List(#(String, String)), 24 | ) -> Request(body) { 25 | let new_headers = 26 | list.fold(headers, request.headers, fn(acc, header) { 27 | list.key_set(acc, string.lowercase(header.0), header.1) 28 | }) 29 | Request(..request, headers: new_headers) 30 | } 31 | 32 | pub fn set_header( 33 | request: Request(body), 34 | header: #(String, String), 35 | ) -> Request(body) { 36 | let key = string.lowercase(header.0) 37 | let value = header.1 38 | 39 | request.set_header(request, key, value) 40 | } 41 | 42 | pub fn set_query_string( 43 | request: Request(body), 44 | query: Option(String), 45 | ) -> Request(body) { 46 | Request(..request, query: query) 47 | } 48 | -------------------------------------------------------------------------------- /src/glitch/types/reward.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder, type Dynamic} 2 | import gleam/result 3 | 4 | pub type Reward { 5 | Reward(id: String, title: String, cost: Int, prompt: String) 6 | } 7 | 8 | pub fn reward_decoder() -> Decoder(Reward) { 9 | dynamic.decode4( 10 | Reward, 11 | dynamic.field("id", dynamic.string), 12 | dynamic.field("title", dynamic.string), 13 | dynamic.field("cost", dynamic.int), 14 | dynamic.field("prompt", dynamic.string), 15 | ) 16 | } 17 | 18 | pub type Status { 19 | Canceled 20 | Fulfilled 21 | Unfulfilled 22 | Unknown 23 | } 24 | 25 | pub fn rewards_status_to_string(reward_status: Status) -> String { 26 | case reward_status { 27 | Canceled -> "canceled" 28 | Fulfilled -> "fulfilled" 29 | Unfulfilled -> "unfulfilled" 30 | Unknown -> "unknown" 31 | } 32 | } 33 | 34 | pub fn reward_status_from_string(string: String) -> Result(Status, Nil) { 35 | case string { 36 | "canceled" -> Ok(Canceled) 37 | "fulfilled" -> Ok(Fulfilled) 38 | "unfulfilled" -> Ok(Unfulfilled) 39 | "unknown" -> Ok(Unknown) 40 | _ -> Error(Nil) 41 | } 42 | } 43 | 44 | pub fn reward_status_decoder() -> Decoder(Status) { 45 | fn(data: Dynamic) { 46 | use string <- result.try(dynamic.string(data)) 47 | 48 | string 49 | |> reward_status_from_string 50 | |> result.replace_error([ 51 | dynamic.DecodeError( 52 | expected: "RewardsStatus", 53 | found: "String(" <> string <> ")", 54 | path: [], 55 | ), 56 | ]) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/glitch/api/client.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http.{type Header, Get, Post} 2 | import gleam/result 3 | import glitch/api/api 4 | import glitch/api/api_request.{type TwitchApiRequest} 5 | import glitch/api/api_response.{type TwitchApiResponse} 6 | import glitch/auth/auth_provider.{type AuthProvider} 7 | import glitch/error.{type TwitchError} 8 | import glitch/types/access_token.{type AccessToken} 9 | 10 | pub opaque type Client { 11 | Client(auth_provider: AuthProvider) 12 | } 13 | 14 | pub fn new(auth_provider: AuthProvider) -> Client { 15 | Client(auth_provider) 16 | } 17 | 18 | pub fn client_id(client: Client) -> String { 19 | auth_provider.client_id(client.auth_provider) 20 | } 21 | 22 | pub fn access_token(client: Client) -> Result(AccessToken, TwitchError) { 23 | auth_provider.access_token(client.auth_provider) 24 | } 25 | 26 | fn headers(client: Client) -> Result(List(Header), TwitchError) { 27 | let client_id = auth_provider.client_id(client.auth_provider) 28 | 29 | use access_token <- result.try(access_token(client)) 30 | 31 | let authorization = "Bearer " <> access_token.token(access_token) 32 | 33 | Ok([ 34 | #("Authorization", authorization), 35 | #("Client-Id", client_id), 36 | #("Content-Type", "application/json"), 37 | ]) 38 | } 39 | 40 | pub fn get( 41 | client: Client, 42 | request: TwitchApiRequest, 43 | ) -> Result(TwitchApiResponse(String), TwitchError) { 44 | use headers <- result.try(headers(client)) 45 | 46 | request 47 | |> api_request.merge_headers(headers, api_request.headers(request)) 48 | |> api_request.set_method(Get) 49 | |> api.send 50 | } 51 | 52 | pub fn post( 53 | client: Client, 54 | request: TwitchApiRequest, 55 | ) -> Result(TwitchApiResponse(String), TwitchError) { 56 | use headers <- result.try(headers(client)) 57 | 58 | request 59 | |> api_request.merge_headers(headers, api_request.headers(request)) 60 | |> api_request.set_method(Post) 61 | |> api.send 62 | } 63 | -------------------------------------------------------------------------------- /src/glitch/api/chat.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/json.{type DecodeError} 3 | import gleam/option.{type Option} 4 | import gleam/result 5 | import glitch/api/api_request 6 | import glitch/api/api_response 7 | import glitch/api/client.{type Client} 8 | import glitch/error.{type TwitchError} 9 | import glitch/extended/json_ext 10 | 11 | pub type Message { 12 | Message(message_id: String, is_sent: Bool) 13 | } 14 | 15 | fn message_decoder() -> Decoder(Message) { 16 | dynamic.decode2( 17 | Message, 18 | dynamic.field("message_id", dynamic.string), 19 | dynamic.field("is_sent", dynamic.bool), 20 | ) 21 | } 22 | 23 | pub fn message_from_json(json_string: String) -> Result(Message, DecodeError) { 24 | json.decode(json_string, message_decoder()) 25 | } 26 | 27 | pub type SendMessageRequest { 28 | SendMessageRequest( 29 | broadcaster_id: String, 30 | sender_id: String, 31 | message: String, 32 | reply_parent_message_id: Option(String), 33 | ) 34 | } 35 | 36 | pub const new_send_message_request = SendMessageRequest 37 | 38 | fn send_message_request_to_json(request: SendMessageRequest) -> String { 39 | json.object([ 40 | #("broadcaster_id", json.string(request.broadcaster_id)), 41 | #("sender_id", json.string(request.sender_id)), 42 | #("message", json.string(request.message)), 43 | #( 44 | "reply_parent_message_id", 45 | json_ext.option(request.reply_parent_message_id, json.string), 46 | ), 47 | ]) 48 | |> json.to_string 49 | } 50 | 51 | pub fn send_message( 52 | client: Client, 53 | request: SendMessageRequest, 54 | ) -> Result(List(Message), TwitchError) { 55 | let api_req = 56 | api_request.new_helix_request() 57 | |> api_request.set_body(send_message_request_to_json(request)) 58 | |> api_request.set_path("chat/messages") 59 | 60 | use response <- result.try(client.post(client, api_req)) 61 | 62 | api_response.get_list_data(response, message_decoder()) 63 | } 64 | -------------------------------------------------------------------------------- /src/glitch/types/condition.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/json.{type DecodeError as JsonDecodeError, type Json} 3 | import gleam/option.{type Option} 4 | import glitch/extended/json_ext 5 | 6 | pub type Condition { 7 | Condition( 8 | broadcaster_user_id: Option(String), 9 | from_broadcaster_id: Option(String), 10 | moderator_user_id: Option(String), 11 | to_broadcaster_id_user_id: Option(String), 12 | reward_id: Option(String), 13 | client_id: Option(String), 14 | extension_client_id: Option(String), 15 | user_id: Option(String), 16 | ) 17 | } 18 | 19 | pub fn decoder() -> Decoder(Condition) { 20 | dynamic.decode8( 21 | Condition, 22 | dynamic.optional_field("broadcaster_user_id", dynamic.string), 23 | dynamic.optional_field("from_broadcaster_id", dynamic.string), 24 | dynamic.optional_field("moderator_user_id", dynamic.string), 25 | dynamic.optional_field("to_broadcaster_id_user_id", dynamic.string), 26 | dynamic.optional_field("reward_id", dynamic.string), 27 | dynamic.optional_field("client_id", dynamic.string), 28 | dynamic.optional_field("extension_client_id", dynamic.string), 29 | dynamic.optional_field("user_id", dynamic.string), 30 | ) 31 | } 32 | 33 | pub fn from_json(json_string: String) -> Result(Condition, JsonDecodeError) { 34 | json.decode(json_string, decoder()) 35 | } 36 | 37 | pub fn to_json(transport: Condition) -> Json { 38 | json.object([ 39 | #( 40 | "broadcaster_user_id", 41 | json_ext.option(transport.broadcaster_user_id, json.string), 42 | ), 43 | #( 44 | "from_broadcaster_id", 45 | json_ext.option(transport.from_broadcaster_id, json.string), 46 | ), 47 | #( 48 | "moderator_user_id", 49 | json_ext.option(transport.moderator_user_id, json.string), 50 | ), 51 | #( 52 | "to_broadcaster_id_user_id", 53 | json_ext.option(transport.to_broadcaster_id_user_id, json.string), 54 | ), 55 | #("reward_id", json_ext.option(transport.reward_id, json.string)), 56 | #("client_id", json_ext.option(transport.client_id, json.string)), 57 | #( 58 | "extension_client_id", 59 | json_ext.option(transport.extension_client_id, json.string), 60 | ), 61 | #("user_id", json_ext.option(transport.user_id, json.string)), 62 | ]) 63 | } 64 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1722555600, 9 | "narHash": "sha256-XOQkdLafnb/p9ij77byFQjDf5m5QYl9b2REiVClC+x4=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "8471fe90ad337a8074e957b69ca4d0089218391d", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1724224976, 24 | "narHash": "sha256-Z/ELQhrSd7bMzTO8r7NZgi9g5emh+aRKoCdaAv5fiO0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "c374d94f1536013ca8e92341b540eba4c22f9c62", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs-lib": { 38 | "locked": { 39 | "lastModified": 1722555339, 40 | "narHash": "sha256-uFf2QeW7eAHlYXuDktm9c25OxOyCoUOQmh5SZ9amE5Q=", 41 | "type": "tarball", 42 | "url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz" 43 | }, 44 | "original": { 45 | "type": "tarball", 46 | "url": "https://github.com/NixOS/nixpkgs/archive/a5d394176e64ab29c852d03346c1fc9b0b7d33eb.tar.gz" 47 | } 48 | }, 49 | "nixpkgs_master": { 50 | "locked": { 51 | "lastModified": 1724420338, 52 | "narHash": "sha256-Sl+rJow37C17iMtlmiXow7ywRavaX0TbCfENZ8SwQfo=", 53 | "owner": "NixOS", 54 | "repo": "nixpkgs", 55 | "rev": "4db93f1fc2fe6c5265230b65552f3a23a3e4056b", 56 | "type": "github" 57 | }, 58 | "original": { 59 | "owner": "NixOS", 60 | "ref": "master", 61 | "repo": "nixpkgs", 62 | "type": "github" 63 | } 64 | }, 65 | "root": { 66 | "inputs": { 67 | "flake-parts": "flake-parts", 68 | "nixpkgs": "nixpkgs", 69 | "nixpkgs_master": "nixpkgs_master" 70 | } 71 | } 72 | }, 73 | "root": "root", 74 | "version": 7 75 | } 76 | -------------------------------------------------------------------------------- /src/glitch/api/eventsub.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/json 3 | import gleam/result 4 | import glitch/api/api_request 5 | import glitch/api/api_response.{type EventSubData} 6 | import glitch/api/client.{type Client} 7 | import glitch/error.{type TwitchError} 8 | import glitch/types/condition.{type Condition} 9 | import glitch/types/subscription.{type SubscriptionStatus, type SubscriptionType} 10 | import glitch/types/transport.{type Transport} 11 | 12 | pub type CreateEventSubscriptionRequest { 13 | CreateEventSubscriptionRequest( 14 | subscription_type: SubscriptionType, 15 | version: String, 16 | condition: Condition, 17 | transport: Transport, 18 | ) 19 | } 20 | 21 | fn send_message_request_to_json( 22 | request: CreateEventSubscriptionRequest, 23 | ) -> String { 24 | json.object([ 25 | #("type", subscription.subscription_type_to_json(request.subscription_type)), 26 | #("version", json.string(request.version)), 27 | #("condition", condition.to_json(request.condition)), 28 | #("transport", transport.to_json(request.transport)), 29 | ]) 30 | |> json.to_string 31 | } 32 | 33 | pub type CreateEventSubSubscriptionResponse { 34 | CreateEventSubSubscriptionResponse( 35 | id: String, 36 | subscription_status: SubscriptionStatus, 37 | subscription_type: SubscriptionType, 38 | version: String, 39 | condition: Condition, 40 | created_at: String, 41 | cost: Int, 42 | ) 43 | } 44 | 45 | fn create_eventsub_subscription_response_decoder() -> Decoder( 46 | CreateEventSubSubscriptionResponse, 47 | ) { 48 | dynamic.decode7( 49 | CreateEventSubSubscriptionResponse, 50 | dynamic.field("id", dynamic.string), 51 | dynamic.field("status", subscription.subscription_status_decoder()), 52 | dynamic.field("type", subscription.subscription_type_decoder()), 53 | dynamic.field("version", dynamic.string), 54 | dynamic.field("condition", condition.decoder()), 55 | dynamic.field("created_at", dynamic.string), 56 | dynamic.field("cost", dynamic.int), 57 | ) 58 | } 59 | 60 | pub fn create_eventsub_subscription( 61 | client: Client, 62 | request: CreateEventSubscriptionRequest, 63 | ) -> Result(EventSubData(List(CreateEventSubSubscriptionResponse)), TwitchError) { 64 | let api_req = 65 | api_request.new_helix_request() 66 | |> api_request.set_body(send_message_request_to_json(request)) 67 | |> api_request.set_path("eventsub/subscriptions") 68 | 69 | use response <- result.try(client.post(client, api_req)) 70 | 71 | api_response.get_eventsub_data( 72 | response, 73 | create_eventsub_subscription_response_decoder(), 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/glitch/types/transport.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/json.{type DecodeError as JsonDecodeError, type Json} 3 | import gleam/option.{type Option} 4 | import gleam/result 5 | import glitch/extended/json_ext 6 | 7 | pub type Transport { 8 | Transport( 9 | method: Method, 10 | callback: Option(String), 11 | // WebHook only 12 | secret: Option(String), 13 | // WebSocket only 14 | session_id: Option(String), 15 | // WebSocket only & Response only 16 | connected_at: Option(String), 17 | // WebSocket only & Response only 18 | disconnected_at: Option(String), 19 | // Conduit only & Response only 20 | conduit_id: Option(String), 21 | ) 22 | } 23 | 24 | pub fn decoder() -> Decoder(Transport) { 25 | dynamic.decode7( 26 | Transport, 27 | dynamic.field("method", method_decoder()), 28 | dynamic.optional_field("callback", dynamic.string), 29 | dynamic.optional_field("secret", dynamic.string), 30 | dynamic.optional_field("session_id", dynamic.string), 31 | dynamic.optional_field("connected_at", dynamic.string), 32 | dynamic.optional_field("disconnected_at", dynamic.string), 33 | dynamic.optional_field("conduit_id", dynamic.string), 34 | ) 35 | } 36 | 37 | pub fn from_json(json_string: String) -> Result(Transport, JsonDecodeError) { 38 | json.decode(json_string, decoder()) 39 | } 40 | 41 | pub fn to_json(transport: Transport) -> Json { 42 | json.object([ 43 | #("method", json.string(method_to_string(transport.method))), 44 | #("callback", json_ext.option(transport.callback, json.string)), 45 | #("secret", json_ext.option(transport.secret, json.string)), 46 | #("session_id", json_ext.option(transport.session_id, json.string)), 47 | #("connected_at", json_ext.option(transport.connected_at, json.string)), 48 | #( 49 | "disconnected_at", 50 | json_ext.option(transport.disconnected_at, json.string), 51 | ), 52 | ]) 53 | } 54 | 55 | pub type Method { 56 | Conduit 57 | WebHook 58 | WebSocket 59 | } 60 | 61 | pub fn method_to_string(method: Method) -> String { 62 | case method { 63 | Conduit -> "conduit" 64 | WebHook -> "webhook" 65 | WebSocket -> "websocket" 66 | } 67 | } 68 | 69 | pub fn method_from_string(string: String) -> Result(Method, Nil) { 70 | case string { 71 | "conduit" -> Ok(Conduit) 72 | "webhook" -> Ok(WebHook) 73 | "websocket" -> Ok(WebSocket) 74 | _ -> Error(Nil) 75 | } 76 | } 77 | 78 | pub fn method_decoder() -> Decoder(Method) { 79 | fn(data: dynamic.Dynamic) { 80 | use string <- result.try(dynamic.string(data)) 81 | 82 | string 83 | |> method_from_string 84 | |> result.replace_error([ 85 | dynamic.DecodeError( 86 | expected: "Method", 87 | found: "String(" <> string <> ")", 88 | path: [], 89 | ), 90 | ]) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/glitch/eventsub/websocket_server.gleam: -------------------------------------------------------------------------------- 1 | import gleam/erlang/process.{type Subject} 2 | import gleam/function 3 | import gleam/http/request 4 | import gleam/io 5 | import gleam/option.{type Option, None, Some} 6 | import gleam/otp/actor.{type StartError} 7 | import gleam/result 8 | import glitch/eventsub/websocket_message.{ 9 | type WebSocketMessage, UnhandledMessage, 10 | } 11 | import stratus 12 | 13 | pub type WebSocketServer = 14 | Subject(Message) 15 | 16 | pub opaque type WebSockerServerState { 17 | State( 18 | websocket_message_mailbox: Subject(WebSocketMessage), 19 | stratus: Option(Subject(stratus.InternalMessage(Nil))), 20 | status: Status, 21 | ) 22 | } 23 | 24 | pub type Status { 25 | Running 26 | Stopped 27 | } 28 | 29 | pub opaque type Message { 30 | Start 31 | Shutdown 32 | } 33 | 34 | // Todo: Look into why we need https/wss 35 | const eventsub_uri = "https://eventsub.wss.twitch.tv/ws" 36 | 37 | pub type StartWebSockterServer = 38 | fn(Nil) -> Result(WebSocketServer, StartError) 39 | 40 | pub fn new(parent_subject, websocket_message_mailbox) -> StartWebSockterServer { 41 | fn(_) { 42 | actor.start_spec(actor.Spec( 43 | init: fn() { 44 | let self = process.new_subject() 45 | 46 | process.send(parent_subject, self) 47 | 48 | let selector = 49 | process.selecting(process.new_selector(), self, function.identity) 50 | 51 | let initial_state = State(websocket_message_mailbox, None, Stopped) 52 | 53 | actor.Ready(initial_state, selector) 54 | }, 55 | init_timeout: 1000, 56 | loop: handle_message, 57 | )) 58 | } 59 | } 60 | 61 | pub fn start(websocket_server: Subject(Message)) -> Nil { 62 | actor.send(websocket_server, Start) 63 | } 64 | 65 | fn handle_message(message: Message, state: WebSockerServerState) { 66 | case message { 67 | Shutdown -> actor.Stop(process.Normal) 68 | Start -> handle_start(state) 69 | } 70 | } 71 | 72 | fn handle_start(state: WebSockerServerState) { 73 | let assert Ok(req) = request.to(eventsub_uri) 74 | 75 | let assert Ok(websocket_client_subject) = 76 | stratus.websocket( 77 | request: req, 78 | init: fn() { #(state, None) }, 79 | loop: fn(message, state, _conn) { 80 | case message { 81 | stratus.Text(message) -> { 82 | let decoded_message = 83 | websocket_message.from_json(message) 84 | |> result.unwrap(UnhandledMessage(message)) 85 | 86 | process.send(state.websocket_message_mailbox, decoded_message) 87 | 88 | actor.continue(state) 89 | } 90 | _ -> { 91 | io.println("Received unexpected message:") 92 | actor.continue(state) 93 | } 94 | } 95 | }, 96 | ) 97 | |> stratus.on_close(fn(state) { 98 | process.send(state.websocket_message_mailbox, websocket_message.Close) 99 | }) 100 | |> stratus.initialize 101 | 102 | actor.continue( 103 | State(..state, stratus: Some(websocket_client_subject), status: Running), 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /oauth/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /src/glitch/types/event.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/option.{type Option} 3 | import glitch/extended/dynamic_ext 4 | import glitch/types/badge.{type Badge} 5 | import glitch/types/cheer.{type Cheer} 6 | import glitch/types/message.{type Message, type MessageType, type Reply} 7 | import glitch/types/reward.{type Reward} 8 | 9 | pub type Event { 10 | MessageEvent( 11 | badges: List(Badge), 12 | broadcaster_user_id: String, 13 | broadcaster_user_login: String, 14 | broadcaster_user_name: String, 15 | chatter_user_id: String, 16 | chatter_user_login: String, 17 | chatter_user_name: String, 18 | color: String, 19 | message: Message, 20 | message_id: String, 21 | message_type: MessageType, 22 | cheer: Option(Cheer), 23 | channel_points_custom_reward_id: Option(String), 24 | reply: Option(Reply), 25 | ) 26 | ChannelPointsCustomRewardRedemptionAddEvent( 27 | id: String, 28 | broadcaster_user_id: String, 29 | broadcaster_user_login: String, 30 | broadcaster_user_name: String, 31 | user_id: String, 32 | user_login: String, 33 | user_name: String, 34 | user_input: String, 35 | status: reward.Status, 36 | reward: Reward, 37 | redeemed_at: String, 38 | ) 39 | } 40 | 41 | pub fn decoder() -> Decoder(Event) { 42 | dynamic.any([ 43 | channel_chat_messsage_event_decoder(), 44 | channel_points_custom_reward_redemption_add_event_decoder(), 45 | ]) 46 | } 47 | 48 | fn channel_chat_messsage_event_decoder() -> Decoder(Event) { 49 | dynamic_ext.decode14( 50 | MessageEvent, 51 | dynamic.field("badges", dynamic.list(of: badge.decoder())), 52 | dynamic.field("broadcaster_user_id", dynamic.string), 53 | dynamic.field("broadcaster_user_login", dynamic.string), 54 | dynamic.field("broadcaster_user_name", dynamic.string), 55 | dynamic.field("chatter_user_id", dynamic.string), 56 | dynamic.field("chatter_user_login", dynamic.string), 57 | dynamic.field("chatter_user_name", dynamic.string), 58 | dynamic.field("color", dynamic.string), 59 | dynamic.field("message", message.decoder()), 60 | dynamic.field("message_id", dynamic.string), 61 | dynamic.field("message_type", message.messsage_type_decoder()), 62 | dynamic.optional_field("cheer", cheer.decoder()), 63 | dynamic.optional_field("channel_points_custom_reward_id", dynamic.string), 64 | dynamic.optional_field("reply", message.reply_decoder()), 65 | ) 66 | } 67 | 68 | fn channel_points_custom_reward_redemption_add_event_decoder() -> Decoder(Event) { 69 | dynamic_ext.decode11( 70 | ChannelPointsCustomRewardRedemptionAddEvent, 71 | dynamic.field("id", dynamic.string), 72 | dynamic.field("broadcaster_user_id", dynamic.string), 73 | dynamic.field("broadcaster_user_login", dynamic.string), 74 | dynamic.field("broadcaster_user_name", dynamic.string), 75 | dynamic.field("user_id", dynamic.string), 76 | dynamic.field("user_login", dynamic.string), 77 | dynamic.field("user_name", dynamic.string), 78 | dynamic.field("user_input", dynamic.string), 79 | dynamic.field("status", reward.reward_status_decoder()), 80 | dynamic.field("reward", reward.reward_decoder()), 81 | dynamic.field("redeemed_at", dynamic.string), 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /src/glitch.gleam: -------------------------------------------------------------------------------- 1 | import dot_env/env 2 | import gleam/erlang/process 3 | import gleam/function 4 | import gleam/io 5 | import gleam/option.{None} 6 | import glitch/api/chat.{SendMessageRequest} 7 | import glitch/api/client as api_client 8 | import glitch/auth/auth_provider 9 | import glitch/auth/token_fetcher 10 | 11 | // import glitch/eventsub/client as eventsub_client 12 | // import glitch/eventsub/websocket_server 13 | import glitch/types/access_token 14 | import glitch/types/scope 15 | 16 | pub fn get_access_token() { 17 | let assert Ok(client_id) = env.get("CLIENT_ID") 18 | let assert Ok(client_secret) = env.get("CLIENT_SECRET") 19 | let scopes = [ 20 | scope.ChannelBot, 21 | scope.ChannelManageRedemptions, 22 | scope.ChannelReadRedemptions, 23 | scope.ChannelReadSubscriptions, 24 | scope.ChatRead, 25 | scope.UserBot, 26 | scope.UserReadChat, 27 | scope.UserWriteChat, 28 | ] 29 | 30 | let mailbox = process.new_subject() 31 | 32 | let assert Ok(token_fetcher) = 33 | token_fetcher.new(client_id, client_secret, scopes, None) 34 | 35 | token_fetcher.fetch(token_fetcher, mailbox) 36 | 37 | let assert Ok(access_token) = 38 | process.new_selector() 39 | |> process.selecting(mailbox, function.identity) 40 | |> process.select_forever 41 | 42 | io.debug(access_token) 43 | 44 | Ok(Nil) 45 | } 46 | 47 | fn new_api_client() { 48 | let assert Ok(access_token_str) = env.get("ACCESS_TOKEN") 49 | let assert Ok(refresh_token_str) = env.get("REFRESH_TOKEN") 50 | let assert Ok(client_id) = env.get("CLIENT_ID") 51 | let assert Ok(client_secret) = env.get("CLIENT_SECRET") 52 | let scopes = [ 53 | scope.ChannelBot, 54 | scope.ChannelManageRedemptions, 55 | scope.ChannelReadRedemptions, 56 | scope.ChannelReadSubscriptions, 57 | scope.ChatRead, 58 | scope.UserBot, 59 | scope.UserReadChat, 60 | scope.UserWriteChat, 61 | ] 62 | 63 | let access_token = 64 | access_token.new_user_access_token( 65 | 0, 66 | 0, 67 | refresh_token_str, 68 | scopes, 69 | access_token_str, 70 | None, 71 | ) 72 | 73 | let auth_provider = 74 | auth_provider.new_refreshing_provider( 75 | access_token, 76 | client_id, 77 | client_secret, 78 | None, 79 | ) 80 | 81 | api_client.new(auth_provider) 82 | } 83 | 84 | pub fn test_chat() { 85 | let client = new_api_client() 86 | 87 | let send_msg_request = 88 | SendMessageRequest("209286766", "209286766", "Hello, chat!", None) 89 | 90 | let assert Ok(_) = chat.send_message(client, send_msg_request) 91 | } 92 | 93 | pub fn test_eventsub() { 94 | // let mailbox = process.new_subject() 95 | // let assert Ok(eventsub) = eventsub_client.new(new_api_client(), mailbox) 96 | // 97 | // let _ = eventsub_client.start(eventsub) 98 | // 99 | process.sleep_forever() 100 | } 101 | 102 | pub fn main() { 103 | // let assert Ok(server) = 104 | // websocket_server.new(process.new_subject(), process.new_subject()) 105 | // websocket_server.start(server) 106 | 107 | // process.sleep_forever() 108 | // // get_access_token() 109 | // // // let _ = test_chat() 110 | test_eventsub() 111 | } 112 | -------------------------------------------------------------------------------- /src/glitch/api/user.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/json.{type DecodeError, type Json} 3 | import gleam/list 4 | import gleam/option.{type Option} 5 | import gleam/result 6 | import gleam/uri.{type Uri} 7 | import glitch/api/api_request 8 | import glitch/api/api_response 9 | import glitch/api/client.{type Client} 10 | import glitch/error.{type TwitchError} 11 | import glitch/extended/dynamic_ext 12 | import glitch/extended/json_ext 13 | 14 | pub type User { 15 | User( 16 | id: String, 17 | login: String, 18 | display_name: String, 19 | user_type: Type, 20 | broadcaster_type: BroadcasterType, 21 | description: String, 22 | profile_image_url: Uri, 23 | offline_image_url: Uri, 24 | view_count: Int, 25 | email: Option(String), 26 | created_at: String, 27 | ) 28 | } 29 | 30 | fn decoder() -> Decoder(User) { 31 | dynamic_ext.decode11( 32 | User, 33 | dynamic.field("id", dynamic.string), 34 | dynamic.field("login", dynamic.string), 35 | dynamic.field("display_name", dynamic.string), 36 | dynamic.field("type", dynamic.string), 37 | dynamic.field("broadcaster_type", dynamic.string), 38 | dynamic.field("description", dynamic.string), 39 | dynamic.field("profile_image_url", dynamic_ext.uri), 40 | dynamic.field("offline_image_url", dynamic_ext.uri), 41 | dynamic.field("view_count", dynamic.int), 42 | dynamic.optional_field("email", dynamic.string), 43 | dynamic.field("created_at", dynamic.string), 44 | ) 45 | } 46 | 47 | pub fn to_json(user: User) -> Json { 48 | json.object([ 49 | #("id", json.string(user.id)), 50 | #("login", json.string(user.login)), 51 | #("display_name", json.string(user.display_name)), 52 | #("type", json.string(user.user_type)), 53 | #("broadcaster_type", json.string(user.broadcaster_type)), 54 | #("description", json.string(user.description)), 55 | #("profile_image_url", json_ext.uri(user.profile_image_url)), 56 | #("offline_image_url", json_ext.uri(user.offline_image_url)), 57 | #("view_count", json.int(user.view_count)), 58 | #("email", json_ext.option(user.email, json.string)), 59 | #("created_at", json.string(user.created_at)), 60 | ]) 61 | } 62 | 63 | pub fn from_json(json_string: String) -> Result(User, DecodeError) { 64 | json.decode(json_string, decoder()) 65 | } 66 | 67 | pub type Type = 68 | String 69 | 70 | pub type BroadcasterType = 71 | String 72 | 73 | pub type GetUsersRequest { 74 | GetUsersRequest( 75 | user_ids: Option(List(String)), 76 | user_logins: Option(List(String)), 77 | ) 78 | } 79 | 80 | pub fn query_params_from_get_users_request( 81 | req: GetUsersRequest, 82 | ) -> List(#(String, String)) { 83 | let to_query_param_list = fn(input: #(String, Option(List(String)))) -> List( 84 | #(String, String), 85 | ) { 86 | input.1 87 | |> option.map(fn(values) { 88 | values 89 | |> list.map(fn(value) { #(input.0, value) }) 90 | }) 91 | |> option.unwrap([]) 92 | } 93 | 94 | [#("id", req.user_ids), #("login", req.user_logins)] 95 | |> list.map(to_query_param_list) 96 | |> list.flatten 97 | } 98 | 99 | pub fn get_users( 100 | client: Client, 101 | request: GetUsersRequest, 102 | ) -> Result(List(User), TwitchError) { 103 | let request = 104 | api_request.new_helix_request() 105 | |> api_request.set_body("") 106 | |> api_request.set_query(query_params_from_get_users_request(request)) 107 | |> api_request.set_path("helix/users") 108 | 109 | use response <- result.try(client.get(client, request)) 110 | 111 | api_response.get_list_data(response, decoder()) 112 | } 113 | -------------------------------------------------------------------------------- /src/glitch/api/api_response.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/http.{type Header} 3 | import gleam/http/response.{type Response, Response} 4 | import gleam/json 5 | import gleam/result 6 | import glitch/error.{type TwitchError, ResponseDecodeError, ResponseError} 7 | 8 | pub opaque type TwitchApiResponse(data) { 9 | TwitchApiResponse(Response(data)) 10 | } 11 | 12 | type TwitchErrorResponse { 13 | TwitchErrorResponse(status: Int, message: String) 14 | } 15 | 16 | pub fn of_http_response(response: Response(String)) -> TwitchApiResponse(String) { 17 | TwitchApiResponse(response) 18 | } 19 | 20 | fn get_http_response(api_response: TwitchApiResponse(data)) -> Response(data) { 21 | case api_response { 22 | TwitchApiResponse(response) -> response 23 | } 24 | } 25 | 26 | pub fn get_body(api_response: TwitchApiResponse(data)) -> data { 27 | get_http_response(api_response).body 28 | } 29 | 30 | pub fn get_headers(api_response: TwitchApiResponse(data)) -> List(Header) { 31 | let http_response = get_http_response(api_response) 32 | http_response.headers 33 | } 34 | 35 | fn error_decoder() -> Decoder(TwitchErrorResponse) { 36 | dynamic.decode2( 37 | TwitchErrorResponse, 38 | dynamic.field("status", dynamic.int), 39 | dynamic.field("message", dynamic.string), 40 | ) 41 | } 42 | 43 | pub fn get_data( 44 | api_response: TwitchApiResponse(String), 45 | data_decoder: Decoder(data), 46 | ) -> Result(data, TwitchError) { 47 | let body = get_body(api_response) 48 | 49 | let error = json.decode(body, error_decoder()) 50 | 51 | case error { 52 | Ok(TwitchErrorResponse(status, message)) -> 53 | Error(ResponseError(status, message)) 54 | _ -> 55 | body 56 | |> json.decode( 57 | dynamic.any([data_decoder, dynamic.field("data", data_decoder)]), 58 | ) 59 | |> result.map_error(ResponseDecodeError) 60 | } 61 | } 62 | 63 | pub fn get_list_data( 64 | api_response: TwitchApiResponse(String), 65 | data_decoder: Decoder(data), 66 | ) -> Result(List(data), TwitchError) { 67 | let body = get_body(api_response) 68 | 69 | let error = json.decode(body, error_decoder()) 70 | 71 | case error { 72 | Ok(TwitchErrorResponse(status, message)) -> 73 | Error(ResponseError(status, message)) 74 | _ -> 75 | body 76 | |> json.decode( 77 | dynamic.any([ 78 | dynamic.field("data", dynamic.list(of: data_decoder)), 79 | dynamic.list(of: data_decoder), 80 | ]), 81 | ) 82 | |> result.map_error(ResponseDecodeError) 83 | } 84 | } 85 | 86 | pub type EventSubData(data) { 87 | EventSubData(data: data, total: Int, total_cost: Int, max_total_cost: Int) 88 | } 89 | 90 | fn eventsub_data_decoder( 91 | data_decoder: Decoder(data), 92 | ) -> Decoder(EventSubData(List(data))) { 93 | dynamic.decode4( 94 | EventSubData, 95 | dynamic.field("data", dynamic.list(data_decoder)), 96 | dynamic.field("total", dynamic.int), 97 | dynamic.field("total_cost", dynamic.int), 98 | dynamic.field("max_total_cost", dynamic.int), 99 | ) 100 | } 101 | 102 | pub fn get_eventsub_data( 103 | api_response: TwitchApiResponse(String), 104 | data_decoder: Decoder(data), 105 | ) -> Result(EventSubData(List(data)), TwitchError) { 106 | let body = get_body(api_response) 107 | 108 | let error = json.decode(body, error_decoder()) 109 | 110 | case error { 111 | Ok(TwitchErrorResponse(status, message)) -> 112 | Error(ResponseError(status, message)) 113 | _ -> 114 | body 115 | |> json.decode(eventsub_data_decoder(data_decoder)) 116 | |> result.map_error(ResponseDecodeError) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/glitch/auth/redirect_server.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bytes_builder 2 | import gleam/erlang/process.{type Subject} 3 | import gleam/http.{Get} 4 | import gleam/http/request.{type Request, Request} 5 | import gleam/http/response.{type Response} 6 | import gleam/list 7 | import gleam/option.{type Option, None, Some} 8 | import gleam/otp/actor.{type StartError} 9 | import gleam/otp/supervisor.{type Message as SupervisorMessage} 10 | import gleam/result 11 | import gleam/uri.{type Uri} 12 | import mist.{type Connection, type ResponseData} 13 | 14 | pub opaque type RedirectServer { 15 | State( 16 | csrf_state: String, 17 | mailbox: Subject(String), 18 | mist: Option(Subject(SupervisorMessage)), 19 | redirect_uri: Uri, 20 | status: Status, 21 | ) 22 | } 23 | 24 | pub type Status { 25 | Running 26 | Stopped 27 | } 28 | 29 | pub type Message { 30 | Shutdown 31 | Start 32 | } 33 | 34 | pub fn new( 35 | csrf_state: String, 36 | mailbox: Subject(String), 37 | redirect_uri: Uri, 38 | ) -> Result(Subject(Message), StartError) { 39 | let state = State(csrf_state, mailbox, None, redirect_uri, Stopped) 40 | 41 | actor.start(state, handle_message) 42 | } 43 | 44 | fn handle_message( 45 | message: Message, 46 | state: RedirectServer, 47 | ) -> actor.Next(Message, RedirectServer) { 48 | case message { 49 | Shutdown -> { 50 | let assert Some(Nil) = 51 | state.mist 52 | |> option.map(process.subject_owner) 53 | |> option.map(process.kill) 54 | 55 | actor.Stop(process.Normal) 56 | } 57 | Start -> { 58 | let port = option.unwrap(state.redirect_uri.port, 3000) 59 | 60 | let assert Ok(mist_subject) = 61 | mist.new(new_router(state)) 62 | |> mist.port(port) 63 | |> mist.start_http 64 | 65 | actor.continue(State(..state, mist: Some(mist_subject), status: Running)) 66 | } 67 | } 68 | } 69 | 70 | pub fn start(server: Subject(Message)) { 71 | actor.send(server, Start) 72 | } 73 | 74 | pub fn shutdown(server: Subject(Message)) { 75 | actor.send(server, Shutdown) 76 | } 77 | 78 | fn new_router(server: RedirectServer) { 79 | let redirect_path = server.redirect_uri.path 80 | 81 | let router = fn(req: Request(Connection)) -> Response(ResponseData) { 82 | case req.method, request.path_segments(req) { 83 | Get, [path] if path == redirect_path -> make_redirect_handler(server)(req) 84 | _, _ -> 85 | response.new(404) 86 | |> response.set_body(mist.Bytes(bytes_builder.new())) 87 | } 88 | } 89 | 90 | router 91 | } 92 | 93 | /// 1024 bytes * 1024 bytes * 10 94 | const ten_megabytes_in_bytes = 10_485_760 95 | 96 | fn make_mist_body(body: Option(String)) { 97 | body 98 | |> option.unwrap("") 99 | |> bytes_builder.from_string 100 | |> mist.Bytes 101 | } 102 | 103 | fn get_code_and_csrf_token_query_params(req) { 104 | let query_params = 105 | request.get_query(req) 106 | |> result.unwrap([]) 107 | 108 | let code_result = list.key_find(query_params, "code") 109 | let csrf_state_result = list.key_find(query_params, "state") 110 | 111 | #(option.from_result(code_result), option.from_result(csrf_state_result)) 112 | } 113 | 114 | fn bad_request_response(message: Option(String)) -> Response(ResponseData) { 115 | response.new(400) 116 | |> response.set_body(make_mist_body(message)) 117 | } 118 | 119 | fn generic_bad_request_response() -> Response(ResponseData) { 120 | bad_request_response(None) 121 | } 122 | 123 | fn ok_response(body: Option(String)) { 124 | response.new(200) 125 | |> response.set_body(make_mist_body(body)) 126 | } 127 | 128 | fn make_redirect_handler(server: RedirectServer) { 129 | let handle_redirect = fn(req: Request(Connection)) -> Response(ResponseData) { 130 | case get_code_and_csrf_token_query_params(req) { 131 | #(Some(code), Some(state)) if state == server.csrf_state -> { 132 | mist.read_body(req, ten_megabytes_in_bytes) 133 | |> result.map(send_response(server, code)) 134 | |> result.lazy_unwrap(generic_bad_request_response) 135 | } 136 | #(Some(_), Some(_)) -> bad_request_response(Some("Invalid csrf state")) 137 | #(Some(_), None) -> bad_request_response(Some("No csrf state received")) 138 | #(None, _) -> bad_request_response(Some("No code received")) 139 | } 140 | } 141 | 142 | handle_redirect 143 | } 144 | 145 | fn send_response(server: RedirectServer, code: String) { 146 | fn(_) { 147 | process.send_after(server.mailbox, 250, code) 148 | ok_response(Some("Successfully received redirect payload from Twitch.")) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/glitch/api/api_request.gleam: -------------------------------------------------------------------------------- 1 | import gleam/http.{type Header, Https} 2 | import gleam/http/request.{type Request, Request} 3 | import glitch/extended/request_ext 4 | 5 | pub opaque type TwitchApiRequest { 6 | HelixApiRequest(Request(String)) 7 | AuthApiRequest(Request(String)) 8 | } 9 | 10 | const api_host = "api.twitch.tv/helix" 11 | 12 | const id_api_host = "id.twitch.tv" 13 | 14 | fn api_request_from_request(request: Request(String)) -> TwitchApiRequest { 15 | HelixApiRequest(request) 16 | } 17 | 18 | fn auth_request_from_request(request: Request(String)) -> TwitchApiRequest { 19 | AuthApiRequest(request) 20 | } 21 | 22 | pub fn new_helix_request() -> TwitchApiRequest { 23 | request.new() 24 | |> request.set_scheme(Https) 25 | |> request.set_host(api_host) 26 | |> api_request_from_request 27 | } 28 | 29 | pub fn new_auth_request() -> TwitchApiRequest { 30 | request.new() 31 | |> request.set_scheme(Https) 32 | |> request.set_host(id_api_host) 33 | |> auth_request_from_request 34 | } 35 | 36 | pub fn to_http_request(request: TwitchApiRequest) -> Request(String) { 37 | case request { 38 | HelixApiRequest(http_request) -> http_request 39 | AuthApiRequest(http_request) -> http_request 40 | } 41 | } 42 | 43 | pub fn is_auth_request(request: TwitchApiRequest) -> Bool { 44 | case request { 45 | HelixApiRequest(_) -> False 46 | AuthApiRequest(_) -> True 47 | } 48 | } 49 | 50 | pub fn is_helix_request(request: TwitchApiRequest) -> Bool { 51 | case request { 52 | HelixApiRequest(_) -> True 53 | AuthApiRequest(_) -> False 54 | } 55 | } 56 | 57 | pub fn headers(request: TwitchApiRequest) -> List(Header) { 58 | to_http_request(request).headers 59 | } 60 | 61 | pub fn set_headers( 62 | request: TwitchApiRequest, 63 | headers: List(Header), 64 | ) -> TwitchApiRequest { 65 | let set_headers_internal = fn(request, headers) { 66 | request 67 | |> to_http_request 68 | |> request_ext.set_headers(headers) 69 | } 70 | 71 | case request { 72 | HelixApiRequest(_) as http_request -> 73 | http_request 74 | |> set_headers_internal(headers) 75 | |> HelixApiRequest 76 | AuthApiRequest(_) as http_request -> 77 | http_request 78 | |> set_headers_internal(headers) 79 | |> AuthApiRequest 80 | } 81 | } 82 | 83 | pub fn set_header(request, header) -> TwitchApiRequest { 84 | let set_header_internal = fn(request, header) { 85 | request 86 | |> to_http_request 87 | |> request_ext.set_header(header) 88 | } 89 | 90 | case request { 91 | HelixApiRequest(_) as http_request -> 92 | http_request 93 | |> set_header_internal(header) 94 | |> HelixApiRequest 95 | AuthApiRequest(_) as http_request -> 96 | http_request 97 | |> set_header_internal(header) 98 | |> AuthApiRequest 99 | } 100 | } 101 | 102 | pub fn merge_headers( 103 | request: TwitchApiRequest, 104 | into base: List(Header), 105 | from overrides: List(Header), 106 | ) -> TwitchApiRequest { 107 | case request { 108 | HelixApiRequest(http_request) -> 109 | HelixApiRequest(request_ext.merge_headers(http_request, base, overrides)) 110 | AuthApiRequest(http_request) -> 111 | AuthApiRequest(request_ext.merge_headers(http_request, base, overrides)) 112 | } 113 | } 114 | 115 | pub fn set_method(request, method) -> TwitchApiRequest { 116 | case request { 117 | HelixApiRequest(http_request) -> 118 | HelixApiRequest(Request(..http_request, method: method)) 119 | AuthApiRequest(http_request) -> 120 | AuthApiRequest(Request(..http_request, method: method)) 121 | } 122 | } 123 | 124 | pub fn set_query(request, query_params) -> TwitchApiRequest { 125 | case request { 126 | HelixApiRequest(http_request) -> 127 | http_request 128 | |> request.set_query(query_params) 129 | |> HelixApiRequest 130 | AuthApiRequest(http_request) -> 131 | http_request 132 | |> request.set_query(query_params) 133 | |> AuthApiRequest 134 | } 135 | } 136 | 137 | pub fn set_body(request, body) -> TwitchApiRequest { 138 | case request { 139 | HelixApiRequest(http_request) -> 140 | HelixApiRequest(Request(..http_request, body: body)) 141 | AuthApiRequest(http_request) -> 142 | AuthApiRequest(Request(..http_request, body: body)) 143 | } 144 | } 145 | 146 | pub fn set_path(request, path) -> TwitchApiRequest { 147 | case request { 148 | HelixApiRequest(http_request) -> 149 | HelixApiRequest(Request(..http_request, path: path)) 150 | AuthApiRequest(http_request) -> 151 | AuthApiRequest(Request(..http_request, path: path)) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/glitch/auth/token_fetcher.gleam: -------------------------------------------------------------------------------- 1 | import gleam/bit_array 2 | import gleam/erlang/os 3 | import gleam/erlang/process.{type Subject} 4 | import gleam/function 5 | import gleam/list 6 | import gleam/option.{type Option, None, Some} 7 | import gleam/otp/actor 8 | import gleam/pair 9 | import gleam/result 10 | import gleam/string 11 | import gleam/uri.{type Uri, Uri} 12 | import glitch/api/auth 13 | import glitch/auth/redirect_server 14 | import glitch/error.{ 15 | type AuthError, type TwitchError, AuthError, TokenFetcherFetchError, 16 | TokenFetcherStartError, 17 | } 18 | import glitch/extended/uri_ext 19 | import glitch/types/access_token.{type AccessToken} 20 | import glitch/types/scope.{type Scope} 21 | import prng/random 22 | import prng/seed 23 | import shellout 24 | 25 | const base_authorization_uri = Uri( 26 | Some("https"), 27 | None, 28 | Some("id.twitch.tv"), 29 | None, 30 | "oauth2/authorize", 31 | None, 32 | None, 33 | ) 34 | 35 | const default_redirect_uri = Uri( 36 | Some("http"), 37 | None, 38 | Some("localhost"), 39 | Some(3000), 40 | "redirect", 41 | None, 42 | None, 43 | ) 44 | 45 | pub type TokenFetcher = 46 | Subject(Message) 47 | 48 | pub opaque type Message { 49 | Fetch(reply_to: Subject(Result(AccessToken, TwitchError))) 50 | } 51 | 52 | pub opaque type TokenFetcherState { 53 | State( 54 | client_id: String, 55 | client_secret: String, 56 | redirect_uri: Option(Uri), 57 | scopes: List(Scope), 58 | ) 59 | } 60 | 61 | pub fn new( 62 | client_id: String, 63 | client_secret: String, 64 | scopes: List(Scope), 65 | redirect_uri: Option(Uri), 66 | ) -> Result(TokenFetcher, TwitchError) { 67 | let state = State(client_id, client_secret, redirect_uri, scopes) 68 | 69 | actor.start(state, handle_message) 70 | |> result.replace_error(AuthError(TokenFetcherStartError)) 71 | } 72 | 73 | fn new_authorization_uri(token_fetcher: TokenFetcherState, csrf_state) -> Uri { 74 | let scopes = 75 | token_fetcher.scopes 76 | |> list.fold("", fn(acc, scope) { 77 | case acc { 78 | "" -> scope.to_string(scope) 79 | _ -> acc <> "+" <> scope.to_string(scope) 80 | } 81 | }) 82 | 83 | let redirect_uri = 84 | token_fetcher.redirect_uri 85 | |> option.unwrap(default_redirect_uri) 86 | |> uri.to_string 87 | 88 | let query_params = [ 89 | #("client_id", token_fetcher.client_id), 90 | #("redirect_uri", redirect_uri), 91 | #("response_type", "code"), 92 | #("scope", scopes), 93 | #("state", csrf_state), 94 | ] 95 | 96 | uri_ext.set_query(base_authorization_uri, query_params) 97 | } 98 | 99 | fn handle_message( 100 | message: Message, 101 | state: TokenFetcherState, 102 | ) -> actor.Next(Message, TokenFetcherState) { 103 | case message { 104 | Fetch(reply_to) -> handle_fetch(state, reply_to) 105 | } 106 | } 107 | 108 | pub fn fetch( 109 | token_fetcher: TokenFetcher, 110 | reply_to: Subject(Result(AccessToken, TwitchError)), 111 | ) -> Nil { 112 | actor.send(token_fetcher, Fetch(reply_to)) 113 | } 114 | 115 | fn handle_fetch( 116 | state: TokenFetcherState, 117 | reply_to: Subject(Result(AccessToken, TwitchError)), 118 | ) { 119 | let mailbox: Subject(String) = process.new_subject() 120 | 121 | let assert Ok(csrf_state) = 122 | random.bit_array() 123 | |> random.step(seed.random()) 124 | |> pair.first 125 | |> bit_array.to_string 126 | 127 | let redirect_uri = option.unwrap(state.redirect_uri, default_redirect_uri) 128 | 129 | let assert Ok(server) = redirect_server.new(csrf_state, mailbox, redirect_uri) 130 | 131 | redirect_server.start(server) 132 | 133 | let authorize_uri = 134 | state 135 | |> new_authorization_uri(csrf_state) 136 | |> uri.to_string 137 | 138 | let assert Ok(_) = case os.family() { 139 | os.WindowsNt -> 140 | shellout.command( 141 | "cmd", 142 | ["/c", "start", string.replace(authorize_uri, "&", "^&")], 143 | ".", 144 | [], 145 | ) 146 | _ -> shellout.command("open", [authorize_uri], ".", []) 147 | } 148 | 149 | let code: String = 150 | process.new_selector() 151 | |> process.selecting(mailbox, function.identity) 152 | |> process.select_forever 153 | 154 | let request = 155 | auth.new_authorization_code_grant_request( 156 | state.client_id, 157 | state.client_secret, 158 | code, 159 | redirect_uri, 160 | ) 161 | 162 | let response = 163 | auth.get_token(request) 164 | |> result.map_error(fn(error) { 165 | AuthError(TokenFetcherFetchError(cause: error)) 166 | }) 167 | 168 | redirect_server.shutdown(server) 169 | 170 | actor.send(reply_to, response) 171 | 172 | actor.continue(state) 173 | } 174 | -------------------------------------------------------------------------------- /src/glitch/types/message.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/option.{type Option} 3 | import gleam/result 4 | import glitch/types/cheermote.{type Cheermote} 5 | import glitch/types/emote.{type Emote} 6 | 7 | pub type Message { 8 | Message(text: String, fragments: List(Fragment)) 9 | } 10 | 11 | pub fn decoder() -> Decoder(Message) { 12 | dynamic.decode2( 13 | Message, 14 | dynamic.field("text", dynamic.string), 15 | dynamic.field("fragments", dynamic.list(of: message_fragment_decoder())), 16 | ) 17 | } 18 | 19 | pub type MessageType { 20 | Text 21 | ChannelPointsHighlighted 22 | ChannelPointsSubOnly 23 | UserIntro 24 | } 25 | 26 | pub fn messsage_type_to_string(messsage_type: MessageType) -> String { 27 | case messsage_type { 28 | Text -> "text" 29 | ChannelPointsHighlighted -> "channel_point_highlighted" 30 | ChannelPointsSubOnly -> "channel_points_sub_only" 31 | UserIntro -> "user_intro" 32 | } 33 | } 34 | 35 | pub fn messsage_type_from_string(string: String) -> Result(MessageType, Nil) { 36 | case string { 37 | "text" -> Ok(Text) 38 | "channel_point_highlighted" -> Ok(ChannelPointsHighlighted) 39 | "channel_points_sub_only" -> Ok(ChannelPointsSubOnly) 40 | "user_intro" -> Ok(UserIntro) 41 | _ -> Error(Nil) 42 | } 43 | } 44 | 45 | pub fn messsage_type_decoder() -> Decoder(MessageType) { 46 | fn(data: dynamic.Dynamic) { 47 | use string <- result.try(dynamic.string(data)) 48 | 49 | string 50 | |> messsage_type_from_string 51 | |> result.replace_error([ 52 | dynamic.DecodeError( 53 | expected: "MessageType", 54 | found: "String(" <> string <> ")", 55 | path: [], 56 | ), 57 | ]) 58 | } 59 | } 60 | 61 | pub type Fragment { 62 | Fragment( 63 | fragment_type: FragmentType, 64 | text: String, 65 | cheermote: Option(Cheermote), 66 | emote: Option(Emote), 67 | mention: Option(Mention), 68 | ) 69 | } 70 | 71 | fn message_fragment_decoder() -> Decoder(Fragment) { 72 | dynamic.decode5( 73 | Fragment, 74 | dynamic.field("type", fragment_type_decoder()), 75 | dynamic.field("text", dynamic.string), 76 | dynamic.optional_field("cheermote", cheermote.decoder()), 77 | dynamic.optional_field("emote", emote.decoder()), 78 | dynamic.optional_field("mention", mention_decoder()), 79 | ) 80 | } 81 | 82 | pub type FragmentType { 83 | TextFragment 84 | CheermoteFragment 85 | EmoteFragment 86 | MentionFragment 87 | } 88 | 89 | pub fn fragment_type_to_string(fragment_type: FragmentType) -> String { 90 | case fragment_type { 91 | TextFragment -> "text" 92 | CheermoteFragment -> "cheermote" 93 | EmoteFragment -> "emote" 94 | MentionFragment -> "mentiond" 95 | } 96 | } 97 | 98 | pub fn fragment_type_from_string(string: String) -> Result(FragmentType, Nil) { 99 | case string { 100 | "text" -> Ok(TextFragment) 101 | "cheermote" -> Ok(CheermoteFragment) 102 | "emote" -> Ok(EmoteFragment) 103 | "mentiond" -> Ok(MentionFragment) 104 | _ -> Error(Nil) 105 | } 106 | } 107 | 108 | pub fn fragment_type_decoder() -> Decoder(FragmentType) { 109 | fn(data: dynamic.Dynamic) { 110 | use string <- result.try(dynamic.string(data)) 111 | 112 | string 113 | |> fragment_type_from_string 114 | |> result.replace_error([ 115 | dynamic.DecodeError( 116 | expected: "FragmentType", 117 | found: "String(" <> string <> ")", 118 | path: [], 119 | ), 120 | ]) 121 | } 122 | } 123 | 124 | pub type Reply { 125 | Reply( 126 | parent_message_id: String, 127 | parent_message_body: String, 128 | parent_user_id: String, 129 | parent_user_name: String, 130 | thread_message_id: String, 131 | thread_user_id: String, 132 | thread_user_name: String, 133 | thread_user_login: String, 134 | ) 135 | } 136 | 137 | pub fn reply_decoder() -> Decoder(Reply) { 138 | dynamic.decode8( 139 | Reply, 140 | dynamic.field("parent_message_id", dynamic.string), 141 | dynamic.field("parent_message_body", dynamic.string), 142 | dynamic.field("parent_message_body", dynamic.string), 143 | dynamic.field("parent_user_name", dynamic.string), 144 | dynamic.field("parent_user_name", dynamic.string), 145 | dynamic.field("thread_user_id", dynamic.string), 146 | dynamic.field("thread_user_id", dynamic.string), 147 | dynamic.field("thread_user_login", dynamic.string), 148 | ) 149 | } 150 | 151 | pub type Mention { 152 | Mention(user_id: String, user_name: String, user_login: String) 153 | } 154 | 155 | pub fn mention_decoder() -> Decoder(Mention) { 156 | dynamic.decode3( 157 | Mention, 158 | dynamic.field("user_id", dynamic.string), 159 | dynamic.field("user_name", dynamic.string), 160 | dynamic.field("user_login", dynamic.string), 161 | ) 162 | } 163 | -------------------------------------------------------------------------------- /src/glitch/types/access_token.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder, type Dynamic} 2 | import gleam/erlang 3 | import gleam/option.{type Option} 4 | import gleam/result 5 | import glitch/error.{type TwitchError, AuthError, InvalidAccessToken} 6 | import glitch/types/scope.{type Scope} 7 | 8 | pub opaque type AccessToken { 9 | AppAccessToken( 10 | expires_in: Int, 11 | obtained_at: Int, 12 | token: String, 13 | last_validated_at: Int, 14 | ) 15 | UserAccessToken( 16 | expires_in: Int, 17 | obtained_at: Int, 18 | refresh_token: String, 19 | scopes: List(Scope), 20 | token: String, 21 | last_validated_at: Int, 22 | ) 23 | } 24 | 25 | type RawAccessToken { 26 | RawAppAccessToken(access_token: String, expires_in: Int) 27 | RawUserAccessToken( 28 | access_token: String, 29 | expires_in: Int, 30 | refresh_token: String, 31 | scope: List(Scope), 32 | ) 33 | } 34 | 35 | pub fn decoder() -> Decoder(AccessToken) { 36 | fn(data: Dynamic) { 37 | data 38 | |> dynamic.decode4( 39 | RawUserAccessToken, 40 | dynamic.field("access_token", dynamic.string), 41 | dynamic.field("expires_in", dynamic.int), 42 | dynamic.field("refresh_token", dynamic.string), 43 | dynamic.field("scope", dynamic.list(scope.decoder())), 44 | ) 45 | |> result.map(from_raw_access_token) 46 | } 47 | } 48 | 49 | fn from_raw_access_token(raw_access_token: RawAccessToken) -> AccessToken { 50 | let now = erlang.system_time(erlang.Second) 51 | 52 | case raw_access_token { 53 | RawAppAccessToken(access_token, expires_in) -> 54 | AppAccessToken( 55 | expires_in: expires_in, 56 | obtained_at: now, 57 | token: access_token, 58 | last_validated_at: now, 59 | ) 60 | RawUserAccessToken(access_token, expires_in, refresh_token, scope) -> 61 | UserAccessToken( 62 | expires_in: expires_in, 63 | obtained_at: now, 64 | refresh_token: refresh_token, 65 | scopes: scope, 66 | token: access_token, 67 | last_validated_at: now, 68 | ) 69 | } 70 | } 71 | 72 | pub fn new_user_access_token( 73 | expires_in: Int, 74 | obtained_at: Int, 75 | refresh_token: String, 76 | scopes: List(Scope), 77 | token: String, 78 | last_validated_at: Option(Int), 79 | ) -> AccessToken { 80 | UserAccessToken( 81 | expires_in, 82 | obtained_at, 83 | refresh_token, 84 | scopes, 85 | token, 86 | option.unwrap(last_validated_at, 0), 87 | ) 88 | } 89 | 90 | pub fn token(access_token: AccessToken) -> String { 91 | case access_token { 92 | UserAccessToken(_, _, _, _, token, _) -> token 93 | AppAccessToken(_, _, token, _) -> token 94 | } 95 | } 96 | 97 | pub fn refresh_token(access_token: AccessToken) -> Result(String, TwitchError) { 98 | case access_token { 99 | UserAccessToken(_, _, refresh_token, _, _, _) -> Ok(refresh_token) 100 | _ -> Error(AuthError(InvalidAccessToken)) 101 | } 102 | } 103 | 104 | pub fn scopes(access_token: AccessToken) -> Result(List(Scope), TwitchError) { 105 | case access_token { 106 | UserAccessToken(_, _, _, scopes, _, _) -> Ok(scopes) 107 | _ -> Error(AuthError(InvalidAccessToken)) 108 | } 109 | } 110 | 111 | pub fn is_expired(access_token: AccessToken) -> Bool { 112 | let #(expires_in, obtained_at) = case access_token { 113 | UserAccessToken(expires_in, obtained_at, _, _, _, _) -> #( 114 | expires_in, 115 | obtained_at, 116 | ) 117 | AppAccessToken(expires_in, obtained_at, _, _) -> #(expires_in, obtained_at) 118 | } 119 | 120 | let now = erlang.system_time(erlang.Second) 121 | let expire_time = obtained_at + expires_in 122 | 123 | now > expire_time 124 | } 125 | 126 | pub fn needs_validated(access_token: AccessToken) -> Bool { 127 | let last_validated_at = case access_token { 128 | UserAccessToken(_, _, _, _, _, last_validated_at) -> last_validated_at 129 | AppAccessToken(_, _, _, last_validated_at) -> last_validated_at 130 | } 131 | 132 | let now = erlang.system_time(erlang.Second) 133 | let one_hour_seconds = 3600 134 | let next_validation_time = last_validated_at + one_hour_seconds 135 | 136 | now >= next_validation_time 137 | } 138 | 139 | pub fn set_expires_in(access_token: AccessToken, expires_in: Int) -> AccessToken { 140 | case access_token { 141 | UserAccessToken( 142 | _, 143 | obtained_at, 144 | refresh_token, 145 | scopes, 146 | token, 147 | last_validated_at, 148 | ) -> 149 | UserAccessToken( 150 | expires_in, 151 | obtained_at, 152 | refresh_token, 153 | scopes, 154 | token, 155 | last_validated_at, 156 | ) 157 | AppAccessToken(_, obtained_at, token, last_validated_at) -> 158 | AppAccessToken(expires_in, obtained_at, token, last_validated_at) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/glitch/auth/auth_provider.gleam: -------------------------------------------------------------------------------- 1 | import gleam/option.{type Option, None, Some} 2 | import gleam/result 3 | import glitch/api/auth.{type GetTokenRequest} 4 | import glitch/error.{ 5 | type AuthError, type TwitchError, AccessTokenExpired, AuthError, 6 | TokenFetcherFetchError, ValidateTokenError, 7 | } 8 | import glitch/types/access_token.{type AccessToken} 9 | 10 | pub opaque type AuthProvider { 11 | ClientCredentialsAuthProvider( 12 | access_token: Option(AccessToken), 13 | client_id: String, 14 | client_secret: String, 15 | ) 16 | RefreshingAuthProvider( 17 | access_token: AccessToken, 18 | client_id: String, 19 | client_secret: String, 20 | on_refresh: Option(fn(AccessToken) -> Nil), 21 | ) 22 | StaticAuthProvider(access_token: AccessToken, client_id: String) 23 | } 24 | 25 | pub fn new_client_credentials_provider( 26 | client_id: String, 27 | client_secret: String, 28 | ) -> AuthProvider { 29 | ClientCredentialsAuthProvider(None, client_id, client_secret) 30 | } 31 | 32 | pub fn new_static_provider( 33 | access_token: AccessToken, 34 | client_id: String, 35 | ) -> AuthProvider { 36 | StaticAuthProvider(access_token, client_id) 37 | } 38 | 39 | pub fn new_refreshing_provider( 40 | access_token: AccessToken, 41 | client_id: String, 42 | client_secret: String, 43 | on_refresh: Option(fn(AccessToken) -> Nil), 44 | ) -> AuthProvider { 45 | RefreshingAuthProvider(access_token, client_id, client_secret, on_refresh) 46 | } 47 | 48 | pub fn client_id(auth_provider: AuthProvider) -> String { 49 | case auth_provider { 50 | ClientCredentialsAuthProvider(_, client_id, _) -> client_id 51 | RefreshingAuthProvider(_, client_id, _, _) -> client_id 52 | StaticAuthProvider(_, client_id) -> client_id 53 | } 54 | } 55 | 56 | pub fn access_token( 57 | auth_provider: AuthProvider, 58 | ) -> Result(AccessToken, TwitchError) { 59 | case auth_provider { 60 | ClientCredentialsAuthProvider(access_token, client_id, client_secret) -> 61 | access_token_for_client_credential_provider( 62 | access_token, 63 | client_id, 64 | client_secret, 65 | ) 66 | RefreshingAuthProvider(access_token, client_id, client_secret, on_refresh) -> { 67 | access_token_for_refreshing_provider( 68 | access_token, 69 | client_id, 70 | client_secret, 71 | on_refresh, 72 | ) 73 | } 74 | StaticAuthProvider(access_token, ..) -> Ok(access_token) 75 | } 76 | } 77 | 78 | fn access_token_for_client_credential_provider( 79 | access_token: Option(AccessToken), 80 | client_id: String, 81 | client_secret: String, 82 | ) -> Result(AccessToken, TwitchError) { 83 | case access_token { 84 | None -> 85 | fetch_token(auth.new_client_credentials_grant_request( 86 | client_id, 87 | client_secret, 88 | )) 89 | Some(access_token) -> { 90 | case 91 | access_token.is_expired(access_token), 92 | access_token.needs_validated(access_token) 93 | { 94 | True, _ -> Error(AuthError(AccessTokenExpired)) 95 | False, True -> { 96 | use validate_token_response <- result.try( 97 | auth.validate_token(access_token) 98 | |> result.map_error(fn(error) { 99 | AuthError(ValidateTokenError(error)) 100 | }), 101 | ) 102 | 103 | Ok(access_token.set_expires_in( 104 | access_token, 105 | validate_token_response.expires_in, 106 | )) 107 | } 108 | False, False -> { 109 | Ok(access_token) 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | fn access_token_for_refreshing_provider( 117 | access_token: AccessToken, 118 | client_id: String, 119 | client_secret: String, 120 | on_refresh: Option(fn(AccessToken) -> Nil), 121 | ) -> Result(AccessToken, TwitchError) { 122 | case 123 | access_token.is_expired(access_token), 124 | access_token.needs_validated(access_token) 125 | { 126 | True, _ -> { 127 | use refresh_token <- result.try(access_token.refresh_token(access_token)) 128 | 129 | let refresh_token_request = 130 | auth.new_refresh_token_grant_request( 131 | client_id, 132 | client_secret, 133 | refresh_token, 134 | ) 135 | 136 | use refreshed_token <- result.try(auth.refresh_token( 137 | refresh_token_request, 138 | )) 139 | 140 | case on_refresh { 141 | None -> Ok(refreshed_token) 142 | Some(on_refresh) -> { 143 | on_refresh(refreshed_token) 144 | Ok(refreshed_token) 145 | } 146 | } 147 | } 148 | False, True -> { 149 | use validate_token_response <- result.try( 150 | auth.validate_token(access_token) 151 | |> result.map_error(fn(error) { AuthError(ValidateTokenError(error)) }), 152 | ) 153 | 154 | Ok(access_token.set_expires_in( 155 | access_token, 156 | validate_token_response.expires_in, 157 | )) 158 | } 159 | False, False -> { 160 | Ok(access_token) 161 | } 162 | } 163 | } 164 | 165 | fn fetch_token(request: GetTokenRequest) { 166 | request 167 | |> auth.get_token 168 | |> result.map_error(fn(error) { 169 | AuthError(TokenFetcherFetchError(cause: error)) 170 | }) 171 | } 172 | -------------------------------------------------------------------------------- /src/glitch/api/auth.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Dynamic} 2 | import gleam/http.{Get, Post} 3 | import gleam/result 4 | import gleam/uri.{type Uri} 5 | import glitch/api/api 6 | import glitch/api/api_request 7 | import glitch/api/api_response 8 | import glitch/error.{type TwitchError, AuthError, InvalidGetTokenRequest} 9 | import glitch/types/access_token.{type AccessToken} 10 | import glitch/types/grant.{ 11 | type GrantType, AuthorizationCode, ClientCredentials, RefreshToken, 12 | } 13 | import glitch/types/scope.{type Scope} 14 | 15 | pub opaque type GetTokenRequest { 16 | AuthorizationCodeGrant( 17 | client_id: String, 18 | client_secret: String, 19 | code: String, 20 | grant_type: GrantType, 21 | redirect_uri: Uri, 22 | ) 23 | ClientCredentialsGrant( 24 | client_id: String, 25 | client_secret: String, 26 | grant_type: GrantType, 27 | ) 28 | RefreshTokenGrant( 29 | client_id: String, 30 | client_secret: String, 31 | grant_type: GrantType, 32 | refresh_token: String, 33 | ) 34 | } 35 | 36 | pub fn new_authorization_code_grant_request( 37 | client_id client_id: String, 38 | client_secret client_secret: String, 39 | code code: String, 40 | redirect_uri redirect_uri: Uri, 41 | ) -> GetTokenRequest { 42 | AuthorizationCodeGrant( 43 | client_id, 44 | client_secret, 45 | code, 46 | AuthorizationCode, 47 | redirect_uri, 48 | ) 49 | } 50 | 51 | pub fn new_client_credentials_grant_request( 52 | client_id client_id: String, 53 | client_secret client_secret: String, 54 | ) -> GetTokenRequest { 55 | ClientCredentialsGrant(client_id, client_secret, ClientCredentials) 56 | } 57 | 58 | pub fn new_refresh_token_grant_request( 59 | client_id client_id: String, 60 | client_secret client_secret: String, 61 | refresh_token refresh_token: String, 62 | ) -> GetTokenRequest { 63 | RefreshTokenGrant(client_id, client_secret, RefreshToken, refresh_token) 64 | } 65 | 66 | fn get_token_request_to_form_data(get_token_request: GetTokenRequest) -> String { 67 | case get_token_request { 68 | AuthorizationCodeGrant( 69 | client_id, 70 | client_secret, 71 | code, 72 | grant_type, 73 | redirect_uri, 74 | ) -> [ 75 | #("client_id", client_id), 76 | #("client_secret", client_secret), 77 | #("code", code), 78 | #("grant_type", grant.to_string(grant_type)), 79 | #("redirect_uri", uri.to_string(redirect_uri)), 80 | ] 81 | ClientCredentialsGrant(client_id, client_secret, grant_type) -> [ 82 | #("client_id", client_id), 83 | #("client_secret", client_secret), 84 | #("grant_type", grant.to_string(grant_type)), 85 | ] 86 | RefreshTokenGrant(client_id, client_secret, grant_type, refresh_token) -> [ 87 | #("client_id", client_id), 88 | #("client_secret", client_secret), 89 | #("grant_type", grant.to_string(grant_type)), 90 | #("refresh_token", uri.percent_encode(refresh_token)), 91 | ] 92 | } 93 | |> uri.query_to_string 94 | } 95 | 96 | pub fn get_token( 97 | get_token_request: GetTokenRequest, 98 | ) -> Result(AccessToken, TwitchError) { 99 | case get_token_request { 100 | RefreshTokenGrant(_, _, _, _) -> Error(AuthError(InvalidGetTokenRequest)) 101 | _ -> { 102 | api_request.new_auth_request() 103 | |> api_request.set_body(get_token_request_to_form_data(get_token_request)) 104 | |> api_request.set_path("oauth2/token") 105 | |> api_request.set_header(#( 106 | "content-type", 107 | "application/x-www-form-urlencoded", 108 | )) 109 | |> api_request.set_method(Post) 110 | |> api.send 111 | |> result.try(api_response.get_data(_, access_token.decoder())) 112 | } 113 | } 114 | } 115 | 116 | pub fn refresh_token( 117 | get_token_request: GetTokenRequest, 118 | ) -> Result(AccessToken, TwitchError) { 119 | case get_token_request { 120 | RefreshTokenGrant(_, _, _, _) -> { 121 | api_request.new_auth_request() 122 | |> api_request.set_body(get_token_request_to_form_data(get_token_request)) 123 | |> api_request.set_path("oauth2/token") 124 | |> api_request.set_header(#( 125 | "content-type", 126 | "application/x-www-form-urlencoded", 127 | )) 128 | |> api_request.set_method(Post) 129 | |> api.send 130 | |> result.try(api_response.get_data(_, access_token.decoder())) 131 | } 132 | _ -> Error(AuthError(InvalidGetTokenRequest)) 133 | } 134 | } 135 | 136 | pub type ValidateTokenResponse { 137 | ValidateTokenResponse( 138 | client_id: String, 139 | login: String, 140 | scopes: List(Scope), 141 | user_id: String, 142 | expires_in: Int, 143 | ) 144 | } 145 | 146 | fn validate_token_response_decoder() { 147 | fn(data: Dynamic) { 148 | data 149 | |> dynamic.decode5( 150 | ValidateTokenResponse, 151 | dynamic.field("client_id", dynamic.string), 152 | dynamic.field("login", dynamic.string), 153 | dynamic.field("scopes", dynamic.list(scope.decoder())), 154 | dynamic.field("user_id", dynamic.string), 155 | dynamic.field("expires_in", dynamic.int), 156 | ) 157 | } 158 | } 159 | 160 | pub fn validate_token(access_token: AccessToken) { 161 | api_request.new_auth_request() 162 | |> api_request.set_path("oauth2/validate") 163 | |> api_request.set_header(#( 164 | "Authorization", 165 | "OAuth " <> access_token.token(access_token), 166 | )) 167 | |> api_request.set_method(Get) 168 | |> api.send 169 | |> result.try(api_response.get_data(_, validate_token_response_decoder())) 170 | } 171 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, 6 | { name = "dot_env", version = "0.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "simplifile"], otp_app = "dot_env", source = "hex", outer_checksum = "AF5C972D6129F67AF3BB00134AB2808D37111A8D61686CFA86F3ADF652548982" }, 7 | { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, 8 | { name = "gleam_bitwise", version = "1.3.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_bitwise", source = "hex", outer_checksum = "B36E1D3188D7F594C7FD4F43D0D2CE17561DE896202017548578B16FE1FE9EFC" }, 9 | { name = "gleam_crypto", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "ADD058DEDE8F0341F1ADE3AAC492A224F15700829D9A3A3F9ADF370F875C51B7" }, 10 | { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, 11 | { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, 12 | { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, 13 | { name = "gleam_json", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB10B0E7BF44282FB25162F1A24C1A025F6B93E777CCF238C4017E4EEF2CDE97" }, 14 | { name = "gleam_otp", version = "0.11.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "517FFB679E44AD71D059F3EF6A17BA6EFC8CB94FA174D52E22FB6768CF684D78" }, 15 | { name = "gleam_stdlib", version = "0.40.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86606B75A600BBD05E539EB59FABC6E307EEEA7B1E5865AFB6D980A93BCB2181" }, 16 | { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, 17 | { name = "glisten", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "48EF7F6D1DCA877C2F49AF35CC33946C7129EEB05A114758A2CC569C708BFAF8" }, 18 | { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, 19 | { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, 20 | { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, 21 | { name = "mist", version = "2.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "981F12FC8BA0656B40099EC876D6F2BEE7B95593610F342E9AB0DC4E663A932F" }, 22 | { name = "prng", version = "3.0.3", build_tools = ["gleam"], requirements = ["gleam_bitwise", "gleam_stdlib"], otp_app = "prng", source = "hex", outer_checksum = "53006736FE23A0F61828C95B505193E10905D8DB76E128F1642D3E571E08F589" }, 23 | { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, 24 | { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" }, 25 | { name = "simplifile", version = "1.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "1D5DFA3A2F9319EC85825F6ED88B8E449F381B0D55A62F5E61424E748E7DDEB0" }, 26 | { name = "stratus", version = "0.9.2", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gramps", "logging"], otp_app = "stratus", source = "hex", outer_checksum = "BC4125B762A1DA4B02EA851EE013909373184EC2B5B023EB79CD256AC2A883E4" }, 27 | { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 28 | ] 29 | 30 | [requirements] 31 | dot_env = { version = "~> 0.2" } 32 | gleam_erlang = { version = "~> 0.25" } 33 | gleam_http = { version = "~> 3.6" } 34 | gleam_httpc = { version = "~> 2.2" } 35 | gleam_json = { version = "~> 2.0" } 36 | gleam_otp = { version = "~> 0.11" } 37 | gleam_stdlib = { version = "~> 0.40.0" } 38 | gleeunit = { version = "~> 1.2" } 39 | logging = { version = "~> 1.0" } 40 | mist = { version = "~> 2.0" } 41 | prng = { version = "~> 3.0" } 42 | shellout = { version = "~> 1.6" } 43 | stratus = { version = "~> 0.9" } 44 | -------------------------------------------------------------------------------- /src/glitch/eventsub/client.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dict.{type Dict} 2 | import gleam/erlang/process.{type Selector, type Subject} 3 | import gleam/function 4 | import gleam/option.{type Option, None, Some} 5 | import gleam/otp/actor.{type StartError} 6 | import gleam/otp/supervisor 7 | import gleam/result 8 | import glitch/api/client.{type Client as ApiClient} as api_client 9 | import glitch/api/eventsub.{ 10 | type CreateEventSubscriptionRequest, CreateEventSubscriptionRequest, 11 | } 12 | import glitch/error.{type TwitchError} 13 | import glitch/eventsub/websocket_message.{type WebSocketMessage} 14 | import glitch/eventsub/websocket_server.{type WebSocketServer} 15 | import glitch/types/event.{type Event} 16 | import glitch/types/subscription.{type SubscriptionType} 17 | 18 | pub type Client = 19 | Subject(Message) 20 | 21 | pub opaque type ClientState { 22 | State( 23 | api_client: ApiClient, 24 | websocket_message_mailbox: Subject(WebSocketMessage), 25 | session_id: Option(String), 26 | subscriptions: Dict(SubscriptionType, Subject(Event)), 27 | status: Status, 28 | websocket_server: WebSocketServer, 29 | ) 30 | } 31 | 32 | pub opaque type Message { 33 | Subscribe(to: SubscriptionType, mailbox: Subject(Event)) 34 | GetState(Subject(ClientState)) 35 | WebSocketMessage(WebSocketMessage) 36 | Start 37 | Stop 38 | } 39 | 40 | pub type Status { 41 | Running 42 | Stopped 43 | } 44 | 45 | pub fn new( 46 | api_client api_client: ApiClient, 47 | websocket_mailbox parent_websocket_message_mailbox: Subject(WebSocketMessage), 48 | parent_subject parent_subject: Subject(Client), 49 | ) -> fn(Nil) -> Result(Client, StartError) { 50 | fn(_) { 51 | actor.start_spec(actor.Spec( 52 | init: fn() { 53 | // Allows parent to send messages to this process 54 | let self = process.new_subject() 55 | process.send(parent_subject, self) 56 | 57 | // Receives messages from parent 58 | let selector: Selector(Message) = 59 | process.selecting(process.new_selector(), self, function.identity) 60 | 61 | // Receive websocket_servers' subject from 62 | let child_subject_mailbox = process.new_subject() 63 | 64 | // Weebsocket server communicates to this process via this subject 65 | let websocket_message_mailbox = process.new_subject() 66 | 67 | // // Lets us send messages to the websocket_server 68 | let start_websocket_server = 69 | websocket_server.new(child_subject_mailbox, websocket_message_mailbox) 70 | 71 | let websocket_server_child_spec = 72 | supervisor.worker(start_websocket_server) 73 | 74 | let assert Ok(_supervisor_subject) = 75 | supervisor.start(supervisor.add(_, websocket_server_child_spec)) 76 | 77 | let assert Ok(websocket_server) = 78 | process.receive(child_subject_mailbox, 1000) 79 | 80 | let initial_state = 81 | State( 82 | api_client, 83 | parent_websocket_message_mailbox, 84 | None, 85 | dict.new(), 86 | Stopped, 87 | websocket_server, 88 | ) 89 | 90 | let websocket_mailbox_selector = 91 | process.selecting( 92 | process.new_selector(), 93 | websocket_message_mailbox, 94 | WebSocketMessage, 95 | ) 96 | 97 | let merged_selector = 98 | process.merge_selector(selector, websocket_mailbox_selector) 99 | 100 | actor.Ready(initial_state, merged_selector) 101 | }, 102 | init_timeout: 1000, 103 | loop: handle_message, 104 | )) 105 | } 106 | } 107 | 108 | pub fn start(client: Client) { 109 | actor.send(client, Start) 110 | } 111 | 112 | pub fn subscribe( 113 | client: Client, 114 | subscription_request: CreateEventSubscriptionRequest, 115 | subscription_event_mailbox, 116 | ) -> Result(Nil, TwitchError) { 117 | let state = actor.call(client, GetState(_), 1000) 118 | 119 | use _ <- result.try(eventsub.create_eventsub_subscription( 120 | state.api_client, 121 | subscription_request, 122 | )) 123 | 124 | actor.send( 125 | client, 126 | Subscribe( 127 | subscription_request.subscription_type, 128 | subscription_event_mailbox, 129 | ), 130 | ) 131 | 132 | Ok(Nil) 133 | } 134 | 135 | pub fn websocket_message_mailbox(client: Client) -> Subject(WebSocketMessage) { 136 | let state = actor.call(client, GetState, 1000) 137 | state.websocket_message_mailbox 138 | } 139 | 140 | pub fn session_id(client: Client) -> Result(String, Nil) { 141 | let state = actor.call(client, GetState, 1000) 142 | 143 | option.to_result(state.session_id, Nil) 144 | } 145 | 146 | pub fn api_client(client: Client) -> api_client.Client { 147 | let state = actor.call(client, GetState, 1000) 148 | state.api_client 149 | } 150 | 151 | fn handle_message(message: Message, state: ClientState) { 152 | case message { 153 | GetState(state_mailbox) -> { 154 | process.send(state_mailbox, state) 155 | actor.continue(state) 156 | } 157 | Subscribe(subscription_type, mailbox) -> { 158 | let subscriptions = 159 | dict.insert(state.subscriptions, subscription_type, mailbox) 160 | actor.continue(State(..state, subscriptions: subscriptions)) 161 | } 162 | WebSocketMessage(message) -> handle_websocket_message(state, message) 163 | Start -> { 164 | websocket_server.start(state.websocket_server) 165 | actor.continue(State(..state, status: Running)) 166 | } 167 | Stop -> panic as "todo" 168 | } 169 | } 170 | 171 | fn handle_websocket_message(state: ClientState, message: WebSocketMessage) { 172 | case message { 173 | websocket_message.Close -> { 174 | // TODO SHUTDOWN 175 | process.send(state.websocket_message_mailbox, message) 176 | actor.continue(state) 177 | } 178 | websocket_message.NotificationMessage(metadata, payload) -> { 179 | process.send(state.websocket_message_mailbox, message) 180 | 181 | let assert Ok(subject) = 182 | dict.get(state.subscriptions, metadata.subscription_type) 183 | 184 | process.send(subject, payload.event) 185 | 186 | actor.continue(state) 187 | } 188 | websocket_message.WelcomeMessage(_metadata, payload) -> { 189 | process.send(state.websocket_message_mailbox, message) 190 | 191 | let session_id = payload.session.id 192 | actor.continue(State(..state, session_id: Some(session_id))) 193 | } 194 | _ -> { 195 | actor.continue(state) 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/glitch/eventsub/websocket_message.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder, type Dynamic} 2 | import gleam/json.{type DecodeError} 3 | import gleam/option.{type Option} 4 | import gleam/result 5 | import gleam/uri.{type Uri} 6 | import glitch/extended/dynamic_ext 7 | import glitch/types/event.{type Event} 8 | import glitch/types/subscription.{type Subscription, type SubscriptionType} 9 | 10 | pub type WebSocketMessage { 11 | Close 12 | NotificationMessage( 13 | metadata: SubscriptionMetadata, 14 | payload: NotificationMessagePayload, 15 | ) 16 | SessionKeepaliveMessage(metadata: Metadata) 17 | UnhandledMessage(raw_message: String) 18 | WelcomeMessage(metadata: Metadata, payload: WelcomeMessagePayload) 19 | } 20 | 21 | pub fn from_json(json_string: String) -> Result(WebSocketMessage, DecodeError) { 22 | json.decode(json_string, decoder()) 23 | } 24 | 25 | pub fn decoder() -> Decoder(WebSocketMessage) { 26 | dynamic.any([ 27 | welcome_message_decoder(), 28 | notification_message_decoder(), 29 | session_keepalive_message_decoder(), 30 | ]) 31 | } 32 | 33 | fn welcome_message_decoder() -> Decoder(WebSocketMessage) { 34 | dynamic.decode2( 35 | WelcomeMessage, 36 | dynamic.field("metadata", metadata_decoder()), 37 | dynamic.field("payload", welcome_message_payload_decoder()), 38 | ) 39 | } 40 | 41 | // TODO: Figure out how to handle "strict", i.e. not allowing additional filds 42 | fn session_keepalive_message_decoder() -> Decoder(WebSocketMessage) { 43 | dynamic.decode1( 44 | SessionKeepaliveMessage, 45 | dynamic.field("metadata", metadata_decoder()), 46 | ) 47 | } 48 | 49 | fn notification_message_decoder() -> Decoder(WebSocketMessage) { 50 | dynamic.decode2( 51 | NotificationMessage, 52 | dynamic.field("metadata", subscription_metadata_decoder()), 53 | dynamic.field("payload", notification_message_payload_decoder()), 54 | ) 55 | } 56 | 57 | pub type MessageType { 58 | Notification 59 | SessionWelcome 60 | SessionKeepalive 61 | SessionReconnect 62 | Revocation 63 | } 64 | 65 | pub fn message_type_to_string(message_type: MessageType) -> String { 66 | case message_type { 67 | Notification -> "notification" 68 | SessionWelcome -> "session_welcome" 69 | SessionKeepalive -> "session_keepalive" 70 | SessionReconnect -> "sessions_reconnect" 71 | Revocation -> "revocation" 72 | } 73 | } 74 | 75 | pub fn message_type_from_string(string: String) -> Result(MessageType, Nil) { 76 | case string { 77 | "notification" -> Ok(Notification) 78 | "session_welcome" -> Ok(SessionWelcome) 79 | "session_keepalive" -> Ok(SessionKeepalive) 80 | "sessions_reconnect" -> Ok(SessionReconnect) 81 | "revocation" -> Ok(Revocation) 82 | _ -> Error(Nil) 83 | } 84 | } 85 | 86 | fn message_type_decoder() -> Decoder(MessageType) { 87 | fn(data: Dynamic) { 88 | use string <- result.try(dynamic.string(data)) 89 | 90 | string 91 | |> message_type_from_string 92 | |> result.replace_error([ 93 | dynamic.DecodeError( 94 | expected: "MessageType", 95 | found: "String(" <> string <> ")", 96 | path: [], 97 | ), 98 | ]) 99 | } 100 | } 101 | 102 | pub type Metadata { 103 | Metadata( 104 | message_id: String, 105 | message_type: MessageType, 106 | message_timestamp: String, 107 | ) 108 | } 109 | 110 | fn metadata_decoder() -> Decoder(Metadata) { 111 | dynamic.decode3( 112 | Metadata, 113 | dynamic.field("message_id", dynamic.string), 114 | dynamic.field("message_type", message_type_decoder()), 115 | dynamic.field("message_timestamp", dynamic.string), 116 | ) 117 | } 118 | 119 | pub type SubscriptionMetadata { 120 | SubscriptionMetadata( 121 | message_id: String, 122 | message_type: MessageType, 123 | message_timestamp: String, 124 | subscription_type: SubscriptionType, 125 | subscription_version: String, 126 | ) 127 | } 128 | 129 | fn subscription_metadata_decoder() -> Decoder(SubscriptionMetadata) { 130 | dynamic.decode5( 131 | SubscriptionMetadata, 132 | dynamic.field("message_id", dynamic.string), 133 | dynamic.field("message_type", message_type_decoder()), 134 | dynamic.field("message_timestamp", dynamic.string), 135 | dynamic.field("subscription_type", subscription.subscription_type_decoder()), 136 | dynamic.field("subscription_version", dynamic.string), 137 | ) 138 | } 139 | 140 | pub type SessionStatus { 141 | Connected 142 | } 143 | 144 | pub fn session_status_to_string(session_status: SessionStatus) -> String { 145 | case session_status { 146 | Connected -> "connnected" 147 | } 148 | } 149 | 150 | pub fn session_status_from_string(string: String) -> Result(SessionStatus, Nil) { 151 | case string { 152 | "connected" -> Ok(Connected) 153 | _ -> Error(Nil) 154 | } 155 | } 156 | 157 | fn session_status_decoder() -> Decoder(SessionStatus) { 158 | fn(data: Dynamic) { 159 | use string <- result.try(dynamic.string(data)) 160 | 161 | string 162 | |> session_status_from_string 163 | |> result.replace_error([ 164 | dynamic.DecodeError( 165 | expected: "SessionStatus", 166 | found: "String(" <> string <> ")", 167 | path: [], 168 | ), 169 | ]) 170 | } 171 | } 172 | 173 | pub type Session { 174 | Session( 175 | id: String, 176 | status: SessionStatus, 177 | connected_at: String, 178 | keepalive_timeout_seconds: Int, 179 | reconnect_url: Option(Uri), 180 | ) 181 | } 182 | 183 | fn session_decoder() -> Decoder(Session) { 184 | dynamic.decode5( 185 | Session, 186 | dynamic.field("id", dynamic.string), 187 | dynamic.field("status", session_status_decoder()), 188 | dynamic.field("connected_at", dynamic.string), 189 | dynamic.field("keepalive_timeout_seconds", dynamic.int), 190 | dynamic.field("reconnect_url", dynamic.optional(dynamic_ext.uri)), 191 | ) 192 | } 193 | 194 | pub type WelcomeMessagePayload { 195 | WelcomeMessagePayload(session: Session) 196 | } 197 | 198 | fn welcome_message_payload_decoder() -> Decoder(WelcomeMessagePayload) { 199 | dynamic.decode1( 200 | WelcomeMessagePayload, 201 | dynamic.field("session", session_decoder()), 202 | ) 203 | } 204 | 205 | pub type NotificationMessagePayload { 206 | NotificationMessagePayload(subscription: Subscription, event: Event) 207 | } 208 | 209 | fn notification_message_payload_decoder() -> Decoder(NotificationMessagePayload) { 210 | dynamic.decode2( 211 | NotificationMessagePayload, 212 | dynamic.field("subscription", subscription.decoder()), 213 | dynamic.field("event", event.decoder()), 214 | ) 215 | } 216 | -------------------------------------------------------------------------------- /src/glitch/extended/dynamic_ext.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{ 2 | type DecodeError, type DecodeErrors, type Decoder, type Dynamic, 3 | } 4 | import gleam/list 5 | import gleam/result 6 | import gleam/uri.{type Uri} 7 | 8 | pub fn uri(dyn: Dynamic) -> Result(Uri, List(DecodeError)) { 9 | result.try(dynamic.string(dyn), fn(str) { 10 | str 11 | |> uri.parse 12 | |> result.replace_error([ 13 | dynamic.DecodeError(expected: "Uri", found: "String", path: []), 14 | ]) 15 | }) 16 | } 17 | 18 | fn decode_field( 19 | from dyn: Dynamic, 20 | using decoder: Decoder(a), 21 | ) -> Result(a, DecodeErrors) { 22 | decoder(dyn) 23 | } 24 | 25 | pub fn decode_bool_field( 26 | dyn: Dynamic, 27 | field_name: String, 28 | ) -> Result(Bool, DecodeErrors) { 29 | decode_field(dyn, dynamic.field(field_name, dynamic.bool)) 30 | } 31 | 32 | pub fn decode_int_field( 33 | dyn: Dynamic, 34 | field_name: String, 35 | ) -> Result(Int, DecodeErrors) { 36 | decode_field(dyn, dynamic.field(field_name, dynamic.int)) 37 | } 38 | 39 | pub fn decode_string_field( 40 | dyn: Dynamic, 41 | field_name: String, 42 | ) -> Result(String, DecodeErrors) { 43 | decode_field(dyn, dynamic.field(field_name, dynamic.string)) 44 | } 45 | 46 | pub fn decode_uri_field( 47 | dyn: Dynamic, 48 | field_name: String, 49 | ) -> Result(Uri, DecodeErrors) { 50 | result.try(decode_string_field(dyn, field_name), fn(str) { 51 | str 52 | |> uri.parse 53 | |> result.replace_error([ 54 | dynamic.DecodeError(expected: "Uri", found: "String", path: [field_name]), 55 | ]) 56 | }) 57 | } 58 | 59 | pub fn decode11( 60 | constructor: fn(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11) -> t, 61 | t1: Decoder(t1), 62 | t2: Decoder(t2), 63 | t3: Decoder(t3), 64 | t4: Decoder(t4), 65 | t5: Decoder(t5), 66 | t6: Decoder(t6), 67 | t7: Decoder(t7), 68 | t8: Decoder(t8), 69 | t9: Decoder(t9), 70 | t10: Decoder(t10), 71 | t11: Decoder(t11), 72 | ) -> Decoder(t) { 73 | fn(x: Dynamic) { 74 | case 75 | t1(x), 76 | t2(x), 77 | t3(x), 78 | t4(x), 79 | t5(x), 80 | t6(x), 81 | t7(x), 82 | t8(x), 83 | t9(x), 84 | t10(x), 85 | t11(x) 86 | { 87 | Ok(a), 88 | Ok(b), 89 | Ok(c), 90 | Ok(d), 91 | Ok(e), 92 | Ok(f), 93 | Ok(g), 94 | Ok(h), 95 | Ok(i), 96 | Ok(j), 97 | Ok(k) 98 | -> Ok(constructor(a, b, c, d, e, f, g, h, i, j, k)) 99 | a, b, c, d, e, f, g, h, i, j, k -> 100 | Error( 101 | list.concat([ 102 | all_errors(a), 103 | all_errors(b), 104 | all_errors(c), 105 | all_errors(d), 106 | all_errors(e), 107 | all_errors(f), 108 | all_errors(g), 109 | all_errors(h), 110 | all_errors(i), 111 | all_errors(j), 112 | all_errors(k), 113 | ]), 114 | ) 115 | } 116 | } 117 | } 118 | 119 | pub fn decode14( 120 | constructor: fn(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14) -> 121 | t, 122 | t1: Decoder(t1), 123 | t2: Decoder(t2), 124 | t3: Decoder(t3), 125 | t4: Decoder(t4), 126 | t5: Decoder(t5), 127 | t6: Decoder(t6), 128 | t7: Decoder(t7), 129 | t8: Decoder(t8), 130 | t9: Decoder(t9), 131 | t10: Decoder(t10), 132 | t11: Decoder(t11), 133 | t12: Decoder(t12), 134 | t13: Decoder(t13), 135 | t14: Decoder(t14), 136 | ) -> Decoder(t) { 137 | fn(x: Dynamic) { 138 | case 139 | t1(x), 140 | t2(x), 141 | t3(x), 142 | t4(x), 143 | t5(x), 144 | t6(x), 145 | t7(x), 146 | t8(x), 147 | t9(x), 148 | t10(x), 149 | t11(x), 150 | t12(x), 151 | t13(x), 152 | t14(x) 153 | { 154 | Ok(a), 155 | Ok(b), 156 | Ok(c), 157 | Ok(d), 158 | Ok(e), 159 | Ok(f), 160 | Ok(g), 161 | Ok(h), 162 | Ok(i), 163 | Ok(j), 164 | Ok(k), 165 | Ok(l), 166 | Ok(m), 167 | Ok(n) 168 | -> Ok(constructor(a, b, c, d, e, f, g, h, i, j, k, l, m, n)) 169 | a, b, c, d, e, f, g, h, i, j, k, l, m, n -> 170 | Error( 171 | list.concat([ 172 | all_errors(a), 173 | all_errors(b), 174 | all_errors(c), 175 | all_errors(d), 176 | all_errors(e), 177 | all_errors(f), 178 | all_errors(g), 179 | all_errors(h), 180 | all_errors(i), 181 | all_errors(j), 182 | all_errors(k), 183 | all_errors(l), 184 | all_errors(m), 185 | all_errors(n), 186 | ]), 187 | ) 188 | } 189 | } 190 | } 191 | 192 | fn all_errors(result: Result(a, List(DecodeError))) -> List(DecodeError) { 193 | case result { 194 | Ok(_) -> [] 195 | Error(errors) -> errors 196 | } 197 | } 198 | 199 | pub fn decode20( 200 | constructor: fn( 201 | t1, 202 | t2, 203 | t3, 204 | t4, 205 | t5, 206 | t6, 207 | t7, 208 | t8, 209 | t9, 210 | t10, 211 | t11, 212 | t12, 213 | t13, 214 | t14, 215 | t15, 216 | t16, 217 | t17, 218 | t18, 219 | t19, 220 | t20, 221 | ) -> 222 | t, 223 | t1: Decoder(t1), 224 | t2: Decoder(t2), 225 | t3: Decoder(t3), 226 | t4: Decoder(t4), 227 | t5: Decoder(t5), 228 | t6: Decoder(t6), 229 | t7: Decoder(t7), 230 | t8: Decoder(t8), 231 | t9: Decoder(t9), 232 | t10: Decoder(t10), 233 | t11: Decoder(t11), 234 | t12: Decoder(t12), 235 | t13: Decoder(t13), 236 | t14: Decoder(t14), 237 | t15: Decoder(t15), 238 | t16: Decoder(t16), 239 | t17: Decoder(t17), 240 | t18: Decoder(t18), 241 | t19: Decoder(t19), 242 | t20: Decoder(t20), 243 | ) -> Decoder(t) { 244 | fn(x: Dynamic) { 245 | case 246 | t1(x), 247 | t2(x), 248 | t3(x), 249 | t4(x), 250 | t5(x), 251 | t6(x), 252 | t7(x), 253 | t8(x), 254 | t9(x), 255 | t10(x), 256 | t11(x), 257 | t12(x), 258 | t13(x), 259 | t14(x), 260 | t15(x), 261 | t16(x), 262 | t17(x), 263 | t18(x), 264 | t19(x), 265 | t20(x) 266 | { 267 | Ok(a), 268 | Ok(b), 269 | Ok(c), 270 | Ok(d), 271 | Ok(e), 272 | Ok(f), 273 | Ok(g), 274 | Ok(h), 275 | Ok(i), 276 | Ok(j), 277 | Ok(k), 278 | Ok(l), 279 | Ok(m), 280 | Ok(n), 281 | Ok(o), 282 | Ok(p), 283 | Ok(q), 284 | Ok(r), 285 | Ok(s), 286 | Ok(t) 287 | -> 288 | Ok(constructor( 289 | a, 290 | b, 291 | c, 292 | d, 293 | e, 294 | f, 295 | g, 296 | h, 297 | i, 298 | j, 299 | k, 300 | l, 301 | m, 302 | n, 303 | o, 304 | p, 305 | q, 306 | r, 307 | s, 308 | t, 309 | )) 310 | a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t -> 311 | Error( 312 | list.concat([ 313 | all_errors(a), 314 | all_errors(b), 315 | all_errors(c), 316 | all_errors(d), 317 | all_errors(e), 318 | all_errors(f), 319 | all_errors(g), 320 | all_errors(h), 321 | all_errors(i), 322 | all_errors(j), 323 | all_errors(k), 324 | all_errors(l), 325 | all_errors(m), 326 | all_errors(n), 327 | all_errors(o), 328 | all_errors(p), 329 | all_errors(q), 330 | all_errors(r), 331 | all_errors(s), 332 | all_errors(t), 333 | ]), 334 | ) 335 | } 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/glitch/types/scope.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder} 2 | import gleam/json.{type DecodeError as JsonDecodeError, type Json} 3 | import gleam/result 4 | 5 | pub type Scope { 6 | AnalyticsReadExtensions 7 | AnalyticsReadGames 8 | BitsRead 9 | ChannelManageAds 10 | ChannelReadAds 11 | ChannelManageBroadcast 12 | ChannelReadCharity 13 | ChannelEditCommercial 14 | ChannelReadEditors 15 | ChannelManageExtensions 16 | ChannelReadGoals 17 | ChannelReadGuestStar 18 | ChannelManageGuestStar 19 | ChannelReadHypeTrain 20 | ChannelManageModerators 21 | ChannelReadPolls 22 | ChannelManagePolls 23 | ChannelReadPredictions 24 | ChannelManagePredictions 25 | ChannelManageRaids 26 | ChannelReadRedemptions 27 | ChannelManageRedemptions 28 | ChannelManageSchedule 29 | ChannelReadStreamKey 30 | ChannelReadSubscriptions 31 | ChannelManageVideos 32 | ChannelReadVips 33 | ChannelManageVips 34 | ClipsEdit 35 | ModerationRead 36 | ModeratorManageAnnouncements 37 | ModeratorManageAutomod 38 | ModeratorReadAutomodSettings 39 | ModeratorManageAutomodSettings 40 | ModeratorManageBannedUsers 41 | ModeratorReadBlockedTerms 42 | ModeratorManageBlockedTerms 43 | ModeratorManageChatMessages 44 | ModeratorReadChatSettings 45 | ModeratorManageChatSettings 46 | ModeratorReadChatters 47 | ModeratorReadFollowers 48 | ModeratorReadGuestStar 49 | ModeratorManageGuestStar 50 | ModeratorReadShieldMode 51 | ModeratorManageShieldMode 52 | ModeratorReadShoutouts 53 | ModeratorManageShoutouts 54 | ModeratorReadUnbanRequests 55 | ModeratorManageUnbanRequests 56 | UserEdit 57 | UserEditFollows 58 | UserReadBlockedUsers 59 | UserManageBlockedUsers 60 | UserReadBroadcast 61 | UserManageChatColor 62 | UserReadEmail 63 | UserReadEmotes 64 | UserReadFollows 65 | UserReadModeratedChannels 66 | UserReadSubscriptions 67 | UserManageWhispers 68 | ChannelBot 69 | ChannelModerate 70 | ChatEdit 71 | ChatRead 72 | UserBot 73 | UserReadChat 74 | UserWriteChat 75 | WhispersRead 76 | WhispersEdit 77 | } 78 | 79 | pub type ScopeError { 80 | InvalidScope(String) 81 | DecodeError(JsonDecodeError) 82 | } 83 | 84 | pub const scopes: List(Scope) = [ 85 | AnalyticsReadExtensions, AnalyticsReadGames, BitsRead, ChannelManageAds, 86 | ChannelReadAds, ChannelManageBroadcast, ChannelReadCharity, 87 | ChannelEditCommercial, ChannelReadEditors, ChannelManageExtensions, 88 | ChannelReadGoals, ChannelReadGuestStar, ChannelManageGuestStar, 89 | ChannelReadHypeTrain, ChannelManageModerators, ChannelReadPolls, 90 | ChannelManagePolls, ChannelReadPredictions, ChannelManagePredictions, 91 | ChannelManageRaids, ChannelReadRedemptions, ChannelManageRedemptions, 92 | ChannelManageSchedule, ChannelReadStreamKey, ChannelReadSubscriptions, 93 | ChannelManageVideos, ChannelReadVips, ChannelManageVips, ClipsEdit, 94 | ModerationRead, ModeratorManageAnnouncements, ModeratorManageAutomod, 95 | ModeratorReadAutomodSettings, ModeratorManageAutomodSettings, 96 | ModeratorManageBannedUsers, ModeratorReadBlockedTerms, 97 | ModeratorManageBlockedTerms, ModeratorManageChatMessages, 98 | ModeratorReadChatSettings, ModeratorManageChatSettings, ModeratorReadChatters, 99 | ModeratorReadFollowers, ModeratorReadGuestStar, ModeratorManageGuestStar, 100 | ModeratorReadShieldMode, ModeratorManageShieldMode, ModeratorReadShoutouts, 101 | ModeratorManageShoutouts, ModeratorReadUnbanRequests, 102 | ModeratorManageUnbanRequests, UserEdit, UserEditFollows, UserReadBlockedUsers, 103 | UserManageBlockedUsers, UserReadBroadcast, UserManageChatColor, UserReadEmail, 104 | UserReadEmotes, UserReadFollows, UserReadModeratedChannels, 105 | UserReadSubscriptions, UserManageWhispers, ChannelBot, ChannelModerate, 106 | ChatEdit, ChatRead, UserBot, UserReadChat, UserWriteChat, WhispersRead, 107 | WhispersEdit, 108 | ] 109 | 110 | pub fn to_string(scope: Scope) -> String { 111 | case scope { 112 | AnalyticsReadExtensions -> "analytics:read:extensions" 113 | AnalyticsReadGames -> "analytics:read:games" 114 | BitsRead -> "bits:read" 115 | ChannelManageAds -> "channel:manage:ads" 116 | ChannelReadAds -> "channel:read:ads" 117 | ChannelManageBroadcast -> "channel:manage:broadcast" 118 | ChannelReadCharity -> "channel:read:charity" 119 | ChannelEditCommercial -> "channel:edit:commercial" 120 | ChannelReadEditors -> "channel:read:editors" 121 | ChannelManageExtensions -> "channel:manage:extensions" 122 | ChannelReadGoals -> "channel:read:goals" 123 | ChannelReadGuestStar -> "channel:read:guest_star" 124 | ChannelManageGuestStar -> "channel:manage:guest_star" 125 | ChannelReadHypeTrain -> "channel:read:hype_train" 126 | ChannelManageModerators -> "channel:manage:moderators" 127 | ChannelReadPolls -> "channel:read:polls" 128 | ChannelManagePolls -> "channel:manage:polls" 129 | ChannelReadPredictions -> "channel:read:predictions" 130 | ChannelManagePredictions -> "channel:manage:predictions" 131 | ChannelManageRaids -> "channel:manage:raids" 132 | ChannelReadRedemptions -> "channel:read:redemptions" 133 | ChannelManageRedemptions -> "channel:manage:redemptions" 134 | ChannelManageSchedule -> "channel:manage:schedule" 135 | ChannelReadStreamKey -> "channel:read:stream_key" 136 | ChannelReadSubscriptions -> "channel:read:subscriptions" 137 | ChannelManageVideos -> "channel:manage:videos" 138 | ChannelReadVips -> "channel:read:vips" 139 | ChannelManageVips -> "channel:manage:vips" 140 | ClipsEdit -> "clips:edit" 141 | ModerationRead -> "moderation:read" 142 | ModeratorManageAnnouncements -> "moderator:manage:announcements" 143 | ModeratorManageAutomod -> "moderator:manage:automod" 144 | ModeratorReadAutomodSettings -> "moderator:read:automod_settings" 145 | ModeratorManageAutomodSettings -> "moderator:manage:automod_settings" 146 | ModeratorManageBannedUsers -> "moderator:manage:banned_users" 147 | ModeratorReadBlockedTerms -> "moderator:read:blocked_terms" 148 | ModeratorManageBlockedTerms -> "moderator:manage:blocked_terms" 149 | ModeratorManageChatMessages -> "moderator:manage:chat_messages" 150 | ModeratorReadChatSettings -> "moderator:read:chat_settings" 151 | ModeratorManageChatSettings -> "moderator:manage:chat_settings" 152 | ModeratorReadChatters -> "moderator:read:chatters" 153 | ModeratorReadFollowers -> "moderator:read:followers" 154 | ModeratorReadGuestStar -> "moderator:read:guest_star" 155 | ModeratorManageGuestStar -> "moderator:manage:guest_star" 156 | ModeratorReadShieldMode -> "moderator:read:shield_mode" 157 | ModeratorManageShieldMode -> "moderator:manage:shield_mode" 158 | ModeratorReadShoutouts -> "moderator:read:shoutouts" 159 | ModeratorManageShoutouts -> "moderator:manage:shoutouts" 160 | ModeratorReadUnbanRequests -> "moderator:read:unban_requests" 161 | ModeratorManageUnbanRequests -> "moderator:manage:unban_requests" 162 | UserEdit -> "user:edit" 163 | UserEditFollows -> "user:edit:follows" 164 | UserReadBlockedUsers -> "user:read:blocked_users" 165 | UserManageBlockedUsers -> "user:manage:blocked_users" 166 | UserReadBroadcast -> "user:read:broadcast" 167 | UserManageChatColor -> "user:manage:chat_color" 168 | UserReadEmail -> "user:read:email" 169 | UserReadEmotes -> "user:read:emotes" 170 | UserReadFollows -> "user:read:follows" 171 | UserReadModeratedChannels -> "user:read:moderated_channels" 172 | UserReadSubscriptions -> "user:read:subscriptions" 173 | UserManageWhispers -> "user:manage:whispers" 174 | ChannelBot -> "channel:bot" 175 | ChannelModerate -> "channel:moderate" 176 | ChatEdit -> "chat:edit" 177 | ChatRead -> "chat:read" 178 | UserBot -> "user:bot" 179 | UserReadChat -> "user:read:chat" 180 | UserWriteChat -> "user:write:chat" 181 | WhispersRead -> "whispers:read" 182 | WhispersEdit -> "whispers:edit" 183 | } 184 | } 185 | 186 | pub fn from_string(str: String) -> Result(Scope, ScopeError) { 187 | case str { 188 | "analytics:read:extensions" -> Ok(AnalyticsReadExtensions) 189 | "analytics:read:games" -> Ok(AnalyticsReadGames) 190 | "bits:read" -> Ok(BitsRead) 191 | "channel:manage:ads" -> Ok(ChannelManageAds) 192 | "channel:read:ads" -> Ok(ChannelReadAds) 193 | "channel:manage:broadcast" -> Ok(ChannelManageBroadcast) 194 | "channel:read:charity" -> Ok(ChannelReadCharity) 195 | "channel:edit:commercial" -> Ok(ChannelEditCommercial) 196 | "channel:read:editors" -> Ok(ChannelReadEditors) 197 | "channel:manage:extensions" -> Ok(ChannelManageExtensions) 198 | "channel:read:goals" -> Ok(ChannelReadGoals) 199 | "channel:read:guest_star" -> Ok(ChannelReadGuestStar) 200 | "channel:manage:guest_star" -> Ok(ChannelManageGuestStar) 201 | "channel:read:hype_train" -> Ok(ChannelReadHypeTrain) 202 | "channel:manage:moderators" -> Ok(ChannelManageModerators) 203 | "channel:read:polls" -> Ok(ChannelReadPolls) 204 | "channel:manage:polls" -> Ok(ChannelManagePolls) 205 | "channel:read:predictions" -> Ok(ChannelReadPredictions) 206 | "channel:manage:predictions" -> Ok(ChannelManagePredictions) 207 | "channel:manage:raids" -> Ok(ChannelManageRaids) 208 | "channel:read:redemptions" -> Ok(ChannelReadRedemptions) 209 | "channel:manage:redemptions" -> Ok(ChannelManageRedemptions) 210 | "channel:manage:schedule" -> Ok(ChannelManageSchedule) 211 | "channel:read:stream_key" -> Ok(ChannelReadStreamKey) 212 | "channel:read:subscriptions" -> Ok(ChannelReadSubscriptions) 213 | "channel:manage:videos" -> Ok(ChannelManageVideos) 214 | "channel:read:vips" -> Ok(ChannelReadVips) 215 | "channel:manage:vips" -> Ok(ChannelManageVips) 216 | "clips:edit" -> Ok(ClipsEdit) 217 | "moderation:read" -> Ok(ModerationRead) 218 | "moderator:manage:announcements" -> Ok(ModeratorManageAnnouncements) 219 | "moderator:manage:automod" -> Ok(ModeratorManageAutomod) 220 | "moderator:read:automod_settings" -> Ok(ModeratorReadAutomodSettings) 221 | "moderator:manage:automod_settings" -> Ok(ModeratorManageAutomodSettings) 222 | "moderator:manage:banned_users" -> Ok(ModeratorManageBannedUsers) 223 | "moderator:read:blocked_terms" -> Ok(ModeratorReadBlockedTerms) 224 | "moderator:manage:blocked_terms" -> Ok(ModeratorManageBlockedTerms) 225 | "moderator:manage:chat_messages" -> Ok(ModeratorManageChatMessages) 226 | "moderator:read:chat_settings" -> Ok(ModeratorReadChatSettings) 227 | "moderator:manage:chat_settings" -> Ok(ModeratorManageChatSettings) 228 | "moderator:read:chatters" -> Ok(ModeratorReadChatters) 229 | "moderator:read:followers" -> Ok(ModeratorReadFollowers) 230 | "moderator:read:guest_star" -> Ok(ModeratorReadGuestStar) 231 | "moderator:manage:guest_star" -> Ok(ModeratorManageGuestStar) 232 | "moderator:read:shield_mode" -> Ok(ModeratorReadShieldMode) 233 | "moderator:manage:shield_mode" -> Ok(ModeratorManageShieldMode) 234 | "moderator:read:shoutouts" -> Ok(ModeratorReadShoutouts) 235 | "moderator:manage:shoutouts" -> Ok(ModeratorManageShoutouts) 236 | "moderator:read:unban_requests" -> Ok(ModeratorReadUnbanRequests) 237 | "moderator:manage:unban_requests" -> Ok(ModeratorManageUnbanRequests) 238 | "user:edit" -> Ok(UserEdit) 239 | "user:edit:follows" -> Ok(UserEditFollows) 240 | "user:read:blocked_users" -> Ok(UserReadBlockedUsers) 241 | "user:manage:blocked_users" -> Ok(UserManageBlockedUsers) 242 | "user:read:broadcast" -> Ok(UserReadBroadcast) 243 | "user:manage:chat_color" -> Ok(UserManageChatColor) 244 | "user:read:email" -> Ok(UserReadEmail) 245 | "user:read:emotes" -> Ok(UserReadEmotes) 246 | "user:read:follows" -> Ok(UserReadFollows) 247 | "user:read:moderated_channels" -> Ok(UserReadModeratedChannels) 248 | "user:read:subscriptions" -> Ok(UserReadSubscriptions) 249 | "user:manage:whispers" -> Ok(UserManageWhispers) 250 | "channel:bot" -> Ok(ChannelBot) 251 | "channel:moderate" -> Ok(ChannelModerate) 252 | "chat:edit" -> Ok(ChatEdit) 253 | "chat:read" -> Ok(ChatRead) 254 | "user:bot" -> Ok(UserBot) 255 | "user:read:chat" -> Ok(UserReadChat) 256 | "user:write:chat" -> Ok(UserWriteChat) 257 | "whispers:read" -> Ok(WhispersRead) 258 | "whispers:edit" -> Ok(WhispersEdit) 259 | _ -> Error(InvalidScope(str)) 260 | } 261 | } 262 | 263 | pub fn decoder() -> Decoder(Scope) { 264 | fn(data: dynamic.Dynamic) { 265 | use string <- result.try(dynamic.string(data)) 266 | 267 | string 268 | |> from_string 269 | |> result.replace_error([ 270 | dynamic.DecodeError( 271 | expected: "Scope", 272 | found: "String(" <> string <> ")", 273 | path: [], 274 | ), 275 | ]) 276 | } 277 | } 278 | 279 | pub fn to_json(scope: Scope) -> Json { 280 | scope 281 | |> to_string 282 | |> json.string 283 | } 284 | 285 | pub fn from_json(json_string: String) -> Result(Scope, ScopeError) { 286 | json_string 287 | |> json.decode(dynamic.string) 288 | |> result.map_error(DecodeError) 289 | |> result.try(from_string) 290 | } 291 | -------------------------------------------------------------------------------- /src/glitch/types/subscription.gleam: -------------------------------------------------------------------------------- 1 | import gleam/dynamic.{type Decoder, DecodeError} 2 | import gleam/json.{ 3 | type DecodeError as JsonDecodeError, type Json, UnexpectedFormat, 4 | } 5 | import gleam/result 6 | import glitch/types/condition.{type Condition} 7 | import glitch/types/transport.{type Transport} 8 | 9 | pub type Subscription { 10 | Subscription( 11 | id: String, 12 | subscription_status: SubscriptionStatus, 13 | subscription_type: SubscriptionType, 14 | version: String, 15 | cost: Int, 16 | condition: Condition, 17 | transport: Transport, 18 | ) 19 | } 20 | 21 | pub fn decoder() -> Decoder(Subscription) { 22 | dynamic.decode7( 23 | Subscription, 24 | dynamic.field("id", dynamic.string), 25 | dynamic.field("status", subscription_status_decoder()), 26 | dynamic.field("type", subscription_type_decoder()), 27 | dynamic.field("version", dynamic.string), 28 | dynamic.field("cost", dynamic.int), 29 | dynamic.field("condition", condition.decoder()), 30 | dynamic.field("transport", transport.decoder()), 31 | ) 32 | } 33 | 34 | pub type SubscriptionType { 35 | AutomodMessageHold 36 | AutomodMessageUpdate 37 | AutomodSettingsUpdate 38 | AutomodTermsUpdate 39 | ChannelUpdate 40 | ChannelFollow 41 | ChannelAdBreakBegin 42 | ChannelChatClear 43 | ChannelChatClearUserMessages 44 | ChannelChatMessage 45 | ChannelChatMessageDelete 46 | ChannelChatNotification 47 | ChannelChatSettingsUpdate 48 | ChannelChatUserMessageHold 49 | ChannelChatUserMessageUpdate 50 | ChannelSubscribe 51 | ChannelSubscriptionEnd 52 | ChannelSubscriptionGift 53 | ChannelSubscriptionMessage 54 | ChannelCheer 55 | ChannelRaid 56 | ChannelBan 57 | ChannelUnban 58 | ChannelUnbanRequestCreate 59 | ChannelUnbanRequestResolve 60 | ChannelModerate 61 | ChannelModeratorAdd 62 | ChannelModeratorRemove 63 | ChannelGuestStarSessionBegin 64 | ChannelGuestStarSessionEnd 65 | ChannelGuestStarGuestUpdate 66 | ChannelGuestStarSettingsUpdate 67 | ChannelPointsAutomaticRewardRedemption 68 | ChannelPointsCustomRewardAdd 69 | ChannelPointsCustomRewardUpdate 70 | ChannelPointsCustomRewardRemove 71 | ChannelPointsCustomRewardRedemptionAdd 72 | ChannelPointsCustomRewardRedemptionUpdate 73 | ChannelPollBegin 74 | ChannelPollProgress 75 | ChannelPollEnd 76 | ChannelPredictionBegin 77 | ChannelPredictionProgress 78 | ChannelPredictionLock 79 | ChannelPredictionEnd 80 | ChannelSuspiciousUserMessage 81 | ChannelSuspiciousUserUpdate 82 | ChannelVIPAdd 83 | ChannelVIPRemove 84 | CharityDonation 85 | CharityCampaignStart 86 | CharityCampaignProgress 87 | CharityCampaignStop 88 | ConduitShardDisabled 89 | DropEntitlementGrant 90 | ExtensionBitsTransactionCreate 91 | GoalBegin 92 | GoalProgress 93 | GoalEnd 94 | HypeTrainBegin 95 | HypeTrainProgress 96 | HypeTrainEnd 97 | ShieldModeBegin 98 | ShieldModeEnd 99 | ShoutoutCreate 100 | ShoutoutReceived 101 | StreamOnline 102 | StreamOffline 103 | UserAuthorizationGrant 104 | UserAuthorizationRevoke 105 | UserUpdate 106 | WhisperReceived 107 | } 108 | 109 | pub fn subscription_type_to_string( 110 | subscription_type: SubscriptionType, 111 | ) -> String { 112 | case subscription_type { 113 | AutomodMessageHold -> "automod.message.hold" 114 | AutomodMessageUpdate -> "automod.message.update" 115 | AutomodSettingsUpdate -> "automod.settings.update" 116 | AutomodTermsUpdate -> "automod.terms.update" 117 | ChannelUpdate -> "channel.update" 118 | ChannelFollow -> "channel.follow" 119 | ChannelAdBreakBegin -> "channel.ad_break.begin" 120 | ChannelChatClear -> "channel.chat.clear" 121 | ChannelChatClearUserMessages -> "channel.chat.clear_user_messages" 122 | ChannelChatMessage -> "channel.chat.message" 123 | ChannelChatMessageDelete -> "channel.chat.message_delete" 124 | ChannelChatNotification -> "channel.chat.notification" 125 | ChannelChatSettingsUpdate -> "channel.chat_settings.update" 126 | ChannelChatUserMessageHold -> "channel.chat.user_message_hold" 127 | ChannelChatUserMessageUpdate -> "channel.chat.user_message_update" 128 | ChannelSubscribe -> "channel.subscribe" 129 | ChannelSubscriptionEnd -> "channel.subscription.end" 130 | ChannelSubscriptionGift -> "channel.subscription.gift" 131 | ChannelSubscriptionMessage -> "channel.subscription.message" 132 | ChannelCheer -> "channel.cheer" 133 | ChannelRaid -> "channel.raid" 134 | ChannelBan -> "channel.ban" 135 | ChannelUnban -> "channel.unban" 136 | ChannelUnbanRequestCreate -> "channel.unban_request.create" 137 | ChannelUnbanRequestResolve -> "channel.unban_request.resolve" 138 | ChannelModerate -> "channel.moderate" 139 | ChannelModeratorAdd -> "channel.moderator.add" 140 | ChannelModeratorRemove -> "channel.moderator.remove" 141 | ChannelGuestStarSessionBegin -> "channel.guest_star_session.begin" 142 | ChannelGuestStarSessionEnd -> "channel.guest_star_session.end" 143 | ChannelGuestStarGuestUpdate -> "channel.guest_star_guest.update" 144 | ChannelGuestStarSettingsUpdate -> "channel.guest_star_settings.update" 145 | ChannelPointsAutomaticRewardRedemption -> 146 | "channel.channel_points_automatic_reward_redemption.add" 147 | ChannelPointsCustomRewardAdd -> "channel.channel_points_custom_reward.add" 148 | ChannelPointsCustomRewardUpdate -> 149 | "channel.channel_points_custom_reward.update" 150 | ChannelPointsCustomRewardRemove -> 151 | "channel.channel_points_custom_reward.remove" 152 | ChannelPointsCustomRewardRedemptionAdd -> 153 | "channel.channel_points_custom_reward_redemption.add" 154 | ChannelPointsCustomRewardRedemptionUpdate -> 155 | "channel.channel_points_custom_reward_redemption.update" 156 | ChannelPollBegin -> "channel.poll.begin" 157 | ChannelPollProgress -> "channel.poll.progress" 158 | ChannelPollEnd -> "channel.poll.end" 159 | ChannelPredictionBegin -> "channel.prediction.begin" 160 | ChannelPredictionProgress -> "channel.prediction.progress" 161 | ChannelPredictionLock -> "channel.prediction.lock" 162 | ChannelPredictionEnd -> "channel.prediction.end" 163 | ChannelSuspiciousUserMessage -> "channel.suspicious_user.message" 164 | ChannelSuspiciousUserUpdate -> "channel.suspicious_user.update" 165 | ChannelVIPAdd -> "channel.vip.add" 166 | ChannelVIPRemove -> "channel.vip.remove" 167 | CharityDonation -> "channel.charity_campaign.donate" 168 | CharityCampaignStart -> "channel.charity_campaign.start" 169 | CharityCampaignProgress -> "channel.charity_campaign.progress" 170 | CharityCampaignStop -> "channel.charity_campaign.stop" 171 | ConduitShardDisabled -> "conduit.shard.disabled" 172 | DropEntitlementGrant -> "drop.entitlement.grant" 173 | ExtensionBitsTransactionCreate -> "extension.bits_transaction.create" 174 | GoalBegin -> "channel.goal.begin" 175 | GoalProgress -> "channel.goal.progress" 176 | GoalEnd -> "channel.goal.end" 177 | HypeTrainBegin -> "channel.hype_train.begin" 178 | HypeTrainProgress -> "channel.hype_train.progress" 179 | HypeTrainEnd -> "channel.hype_train.end" 180 | ShieldModeBegin -> "channel.shield_mode.begin" 181 | ShieldModeEnd -> "channel.shield_mode.end" 182 | ShoutoutCreate -> "channel.shoutout.create" 183 | ShoutoutReceived -> "channel.shoutout.receive" 184 | StreamOnline -> "stream.online" 185 | StreamOffline -> "stream.offline" 186 | UserAuthorizationGrant -> "user.authorization.grant" 187 | UserAuthorizationRevoke -> "user.authorization.revoke" 188 | UserUpdate -> "user.update" 189 | WhisperReceived -> "user.whisper.message" 190 | } 191 | } 192 | 193 | pub fn subscription_type_from_string( 194 | str: String, 195 | ) -> Result(SubscriptionType, Nil) { 196 | case str { 197 | "automod.message.hold" -> Ok(AutomodMessageHold) 198 | "automod.message.update" -> Ok(AutomodMessageUpdate) 199 | "automod.settings.update" -> Ok(AutomodSettingsUpdate) 200 | "automod.terms.update" -> Ok(AutomodTermsUpdate) 201 | "channel.update" -> Ok(ChannelUpdate) 202 | "channel.follow" -> Ok(ChannelFollow) 203 | "channel.ad_break.begin" -> Ok(ChannelAdBreakBegin) 204 | "channel.chat.clear" -> Ok(ChannelChatClear) 205 | "channel.chat.clear_user_messages" -> Ok(ChannelChatClearUserMessages) 206 | "channel.chat.message" -> Ok(ChannelChatMessage) 207 | "channel.chat.message_delete" -> Ok(ChannelChatMessageDelete) 208 | "channel.chat.notification" -> Ok(ChannelChatNotification) 209 | "channel.chat_settings.update" -> Ok(ChannelChatSettingsUpdate) 210 | "channel.chat.user_message_hold" -> Ok(ChannelChatUserMessageHold) 211 | "channel.chat.user_message_update" -> Ok(ChannelChatUserMessageUpdate) 212 | "channel.subscribe" -> Ok(ChannelSubscribe) 213 | "channel.subscription.end" -> Ok(ChannelSubscriptionEnd) 214 | "channel.subscription.gift" -> Ok(ChannelSubscriptionGift) 215 | "channel.subscription.message" -> Ok(ChannelSubscriptionMessage) 216 | "channel.cheer" -> Ok(ChannelCheer) 217 | "channel.raid" -> Ok(ChannelRaid) 218 | "channel.ban" -> Ok(ChannelBan) 219 | "channel.unban" -> Ok(ChannelUnban) 220 | "channel.unban_request.create" -> Ok(ChannelUnbanRequestCreate) 221 | "channel.unban_request.resolve" -> Ok(ChannelUnbanRequestResolve) 222 | "channel.moderate" -> Ok(ChannelModerate) 223 | "channel.moderator.add" -> Ok(ChannelModeratorAdd) 224 | "channel.moderator.remove" -> Ok(ChannelModeratorRemove) 225 | "channel.guest_star_session.begin" -> Ok(ChannelGuestStarSessionBegin) 226 | "channel.guest_star_session.end" -> Ok(ChannelGuestStarSessionEnd) 227 | "channel.guest_star_guest.update" -> Ok(ChannelGuestStarGuestUpdate) 228 | "channel.guest_star_settings.update" -> Ok(ChannelGuestStarSettingsUpdate) 229 | "channel.channel_points_automatic_reward_redemption.add" -> 230 | Ok(ChannelPointsAutomaticRewardRedemption) 231 | "channel.channel_points_custom_reward.add" -> 232 | Ok(ChannelPointsCustomRewardAdd) 233 | "channel.channel_points_custom_reward.update" -> 234 | Ok(ChannelPointsCustomRewardUpdate) 235 | "channel.channel_points_custom_reward.remove" -> 236 | Ok(ChannelPointsCustomRewardRemove) 237 | "channel.channel_points_custom_reward_redemption.add" -> 238 | Ok(ChannelPointsCustomRewardRedemptionAdd) 239 | "channel.channel_points_custom_reward_redemption.update" -> 240 | Ok(ChannelPointsCustomRewardRedemptionUpdate) 241 | "channel.poll.begin" -> Ok(ChannelPollBegin) 242 | "channel.poll.progress" -> Ok(ChannelPollProgress) 243 | "channel.poll.end" -> Ok(ChannelPollEnd) 244 | "channel.prediction.begin" -> Ok(ChannelPredictionBegin) 245 | "channel.prediction.progress" -> Ok(ChannelPredictionProgress) 246 | "channel.prediction.lock" -> Ok(ChannelPredictionLock) 247 | "channel.prediction.end" -> Ok(ChannelPredictionEnd) 248 | "channel.suspicious_user.message" -> Ok(ChannelSuspiciousUserMessage) 249 | "channel.suspicious_user.update" -> Ok(ChannelSuspiciousUserUpdate) 250 | "channel.vip.add" -> Ok(ChannelVIPAdd) 251 | "channel.vip.remove" -> Ok(ChannelVIPRemove) 252 | "channel.charity_campaign.donate" -> Ok(CharityDonation) 253 | "channel.charity_campaign.start" -> Ok(CharityCampaignStart) 254 | "channel.charity_campaign.progress" -> Ok(CharityCampaignProgress) 255 | "channel.charity_campaign.stop" -> Ok(CharityCampaignStop) 256 | "conduit.shard.disabled" -> Ok(ConduitShardDisabled) 257 | "drop.entitlement.grant" -> Ok(DropEntitlementGrant) 258 | "extension.bits_transaction.create" -> Ok(ExtensionBitsTransactionCreate) 259 | "channel.goal.begin" -> Ok(GoalBegin) 260 | "channel.goal.progress" -> Ok(GoalProgress) 261 | "channel.goal.end" -> Ok(GoalEnd) 262 | "channel.hype_train.begin" -> Ok(HypeTrainBegin) 263 | "channel.hype_train.progress" -> Ok(HypeTrainProgress) 264 | "channel.hype_train.end" -> Ok(HypeTrainEnd) 265 | "channel.shield_mode.begin" -> Ok(ShieldModeBegin) 266 | "channel.shield_mode.end" -> Ok(ShieldModeEnd) 267 | "channel.shoutout.create" -> Ok(ShoutoutCreate) 268 | "channel.shoutout.receive" -> Ok(ShoutoutReceived) 269 | "stream.online" -> Ok(StreamOnline) 270 | "stream.offline" -> Ok(StreamOffline) 271 | "user.authorization.grant" -> Ok(UserAuthorizationGrant) 272 | "user.authorization.revoke" -> Ok(UserAuthorizationRevoke) 273 | "user.update" -> Ok(UserUpdate) 274 | "user.whisper.message" -> Ok(WhisperReceived) 275 | _ -> Error(Nil) 276 | } 277 | } 278 | 279 | pub fn subscription_type_decoder() -> Decoder(SubscriptionType) { 280 | fn(data: dynamic.Dynamic) { 281 | use string <- result.try(dynamic.string(data)) 282 | 283 | string 284 | |> subscription_type_from_string 285 | |> result.replace_error([ 286 | dynamic.DecodeError( 287 | expected: "SubscriptionType", 288 | found: "String(" <> string <> ")", 289 | path: [], 290 | ), 291 | ]) 292 | } 293 | } 294 | 295 | pub fn subscription_type_to_json(subscription_type: SubscriptionType) -> Json { 296 | subscription_type 297 | |> subscription_type_to_string 298 | |> json.string 299 | } 300 | 301 | pub fn subscription_type_from_json( 302 | json_string: String, 303 | ) -> Result(SubscriptionType, JsonDecodeError) { 304 | use string <- result.try(json.decode(json_string, dynamic.string)) 305 | 306 | string 307 | |> subscription_type_from_string 308 | |> result.replace_error( 309 | UnexpectedFormat([ 310 | DecodeError( 311 | expected: "SubscriptionType", 312 | found: "String(" <> json_string <> ")", 313 | path: [], 314 | ), 315 | ]), 316 | ) 317 | } 318 | 319 | pub type SubscriptionStatus { 320 | Enabled 321 | WebHookCallbackVerificationPending 322 | } 323 | 324 | pub fn subscription_status_from_string( 325 | str: String, 326 | ) -> Result(SubscriptionStatus, Nil) { 327 | case str { 328 | "enabled" -> Ok(Enabled) 329 | "webhook_callback_verification_pending" -> Ok(Enabled) 330 | _ -> Error(Nil) 331 | } 332 | } 333 | 334 | pub fn subscription_status_to_string(status: SubscriptionStatus) -> String { 335 | case status { 336 | Enabled -> "enabled" 337 | WebHookCallbackVerificationPending -> 338 | "webhook_callback_verification_pending" 339 | } 340 | } 341 | 342 | pub fn subscription_status_decoder() -> Decoder(SubscriptionStatus) { 343 | fn(data: dynamic.Dynamic) { 344 | use string <- result.try(dynamic.string(data)) 345 | 346 | string 347 | |> subscription_status_from_string 348 | |> result.replace_error([ 349 | dynamic.DecodeError( 350 | expected: "SubscriptionStatus", 351 | found: "String(" <> string <> ")", 352 | path: [], 353 | ), 354 | ]) 355 | } 356 | } 357 | 358 | pub fn subscription_status_to_json( 359 | subscription_status: SubscriptionStatus, 360 | ) -> Json { 361 | subscription_status 362 | |> subscription_status_to_string 363 | |> json.string 364 | } 365 | 366 | pub fn subscription_status_from_json( 367 | json_string: String, 368 | ) -> Result(SubscriptionStatus, JsonDecodeError) { 369 | use string <- result.try(json.decode(json_string, dynamic.string)) 370 | 371 | string 372 | |> subscription_status_from_string 373 | |> result.replace_error( 374 | UnexpectedFormat([ 375 | DecodeError( 376 | expected: "SubscriptionStatus", 377 | found: "String(" <> json_string <> ")", 378 | path: [], 379 | ), 380 | ]), 381 | ) 382 | } 383 | --------------------------------------------------------------------------------