├── src └── slash │ ├── util.clj │ ├── webhook.clj │ ├── core.clj │ ├── gateway.clj │ ├── response.clj │ ├── component │ └── structure.clj │ ├── command │ └── structure.clj │ └── command.clj ├── .gitignore ├── .clj-kondo ├── config.edn └── hooks │ └── slash.clj ├── .github └── workflows │ └── clojure.yml ├── test └── slash │ ├── gateway_test.clj │ ├── core_test.clj │ ├── command │ └── structure_test.clj │ ├── component │ └── structure_test.clj │ └── command_test.clj ├── project.clj ├── LICENSE └── README.md /src/slash/util.clj: -------------------------------------------------------------------------------- 1 | (ns slash.util) 2 | 3 | (defn omission-map [& keyvals] 4 | (reduce (fn [m [key val]] (cond-> m (some? val) (assoc key val))) {} (partition 2 keyvals))) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | profiles.clj 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | /.prepl-port 12 | .clj-kondo/.cache/ 13 | .hgignore 14 | .hg/ 15 | -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:hooks {:analyze-call {slash.command/handler hooks.slash/handler 2 | slash.command/defhandler hooks.slash/defhandler 3 | slash.command/group hooks.slash/group 4 | slash.command/defpaths hooks.slash/defpaths}}} 5 | -------------------------------------------------------------------------------- /.github/workflows/clojure.yml: -------------------------------------------------------------------------------- 1 | name: Clojure CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main develop ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install dependencies 17 | run: lein deps 18 | - name: Run tests 19 | run: lein test 20 | -------------------------------------------------------------------------------- /test/slash/gateway_test.clj: -------------------------------------------------------------------------------- 1 | (ns slash.gateway-test 2 | (:require [slash.gateway :refer :all] 3 | [clojure.test :refer :all])) 4 | 5 | (deftest nop-test 6 | (is (nil? (nop :arg)))) 7 | 8 | (def handler (constantly "foo")) 9 | 10 | (def respond-fn (partial str "bar")) 11 | 12 | (deftest return-mw-test 13 | (is (= "barfoo" ((wrap-response-return handler respond-fn) :any)))) 14 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject com.github.johnnyjayjay/slash "0.6.1-SNAPSHOT" 2 | :description "A library for handling and routing Discord interactions" 3 | :url "https://github.com/JohnnyJayJay/slash" 4 | :license {:name "MIT License" 5 | :url "https://mit-license.org"} 6 | :dependencies [[org.clojure/clojure "1.10.3"] 7 | [frankiesardo/linked "1.3.0"]] 8 | :repl-options {:init-ns slash.core}) 9 | -------------------------------------------------------------------------------- /test/slash/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns slash.core-test 2 | (:require [clojure.test :refer :all] 3 | [slash.core :refer :all])) 4 | 5 | (def handlers 6 | {:ping (constantly :ping) 7 | :application-command (constantly :cmd) 8 | :message-component (constantly :cmp) 9 | :application-command-autocomplete (constantly :ac)}) 10 | 11 | (deftest routing-test 12 | (testing "Interaction handler routing" 13 | (is (= :ping (route-interaction handlers {:type 1}))) 14 | (is (= :cmd (route-interaction handlers {:type 2}))) 15 | (is (= :cmp (route-interaction handlers {:type 3}))) 16 | (is (= :ac (route-interaction handlers {:type 4})))) 17 | (testing "Interaction handler arguments" 18 | (is (= {:type 2 :data :foo} (route-interaction (constantly identity) {:type 2 :data :foo}))))) 19 | -------------------------------------------------------------------------------- /src/slash/webhook.clj: -------------------------------------------------------------------------------- 1 | (ns slash.webhook 2 | "Slash functionality for receiving interactions via outgoing webhook." 3 | (:require [slash.response :refer [pong]])) 4 | 5 | (defn interaction-not-supported 6 | "A ring-compliant handler that takes an interaction and returns a Bad Request response." 7 | [{:keys [type] :as _interaction}] 8 | {:status 400 9 | :headers {"Content-Type" "text/plain"} 10 | :body (str "Interactions of type " type " are not supported")}) 11 | 12 | (def webhook-defaults 13 | "Default webhook interaction handlers. 14 | 15 | Returns a 400 Bad Request response for all interactions except PING, for which it returns a PONG response." 16 | {:ping (constantly pong) 17 | :application-command interaction-not-supported 18 | :message-component interaction-not-supported 19 | :application-command-autocomplete interaction-not-supported 20 | :modal-submit interaction-not-supported}) 21 | -------------------------------------------------------------------------------- /src/slash/core.clj: -------------------------------------------------------------------------------- 1 | (ns slash.core 2 | "Core namespace.") 3 | 4 | (def interaction-types 5 | "A map of interaction type code -> interaction type name keyword. 6 | 7 | See https://discord.com/developers/docs/interactions/slash-commands#interaction-object-interaction-request-type" 8 | {1 :ping 9 | 2 :application-command 10 | 3 :message-component 11 | 4 :application-command-autocomplete 12 | 5 :modal-submit}) 13 | 14 | (defn route-interaction 15 | "Takes a handler map and an interaction and routes the interaction to the correct handler. 16 | 17 | The handler map should map each interaction type to one handler function. 18 | The handler functions take the interaction as a parameter. 19 | See [[slash.gateway/gateway-defaults]] and [[slash.webhook/webhook-defaults]] for default handler maps. 20 | 21 | The interaction object passed to this function must be given as a Clojure map with keywords as keys." 22 | [handlers {:keys [type] :as interaction}] 23 | ((-> type interaction-types handlers) interaction)) 24 | -------------------------------------------------------------------------------- /src/slash/gateway.clj: -------------------------------------------------------------------------------- 1 | (ns slash.gateway 2 | "Slash functionality for receiving interactions via the Discord gateway.") 3 | 4 | (def nop 5 | "No operation function. Returns `nil`." 6 | (constantly nil)) 7 | 8 | (def gateway-defaults 9 | "Default gateway interaction handlers. 10 | 11 | The interaction handlers in this map simply don't do anything." 12 | {:ping nop 13 | :application-command nop 14 | :message-component nop 15 | :application-command-autocomplete nop 16 | :modal-submit nop}) 17 | 18 | (defn wrap-response-return 19 | "Middleware that takes the return value of an interaction handler and consumes it in some way. 20 | 21 | This is useful for responding to interactions via REST. 22 | The interaction handlers can simply return their response and this middleware 23 | uses the given `respond-fn` to send it to Discord. 24 | 25 | `respond-fn` is a function of 3 parameters: interaction id, interaction token and interaction response." 26 | [handler respond-fn] 27 | (fn [{:keys [id token] :as interaction}] 28 | (when-some [response (handler interaction)] 29 | (respond-fn id token response)))) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 JohnnyJayJay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.clj-kondo/hooks/slash.clj: -------------------------------------------------------------------------------- 1 | (ns hooks.slash 2 | (:require [clj-kondo.hooks-api :as api])) 3 | 4 | (defn strip-pattern [pattern] 5 | (api/vector-node (vec (remove api/string-node? (:children pattern))))) 6 | 7 | (defn transform-handler-contents [[pattern interact-sym opt-sym & body]] 8 | (api/list-node 9 | (list* 10 | (api/token-node 'let) 11 | (api/vector-node 12 | [(strip-pattern pattern) (api/token-node nil) 13 | interact-sym (api/token-node nil) 14 | opt-sym (api/token-node nil)]) 15 | body))) 16 | 17 | (defn handler [{:keys [node]}] 18 | {:node (transform-handler-contents (rest (:children node)))}) 19 | 20 | (defn defhandler [{:keys [node]}] 21 | (let [[name & handler-contents] (rest (:children node))] 22 | {:node (api/list-node (list (api/token-node 'def) name (transform-handler-contents handler-contents)))})) 23 | 24 | (defn group [{:keys [node]}] 25 | (let [[pattern & handlers] (rest (:children node))] 26 | {:node (api/list-node 27 | (list* 28 | (api/token-node 'let) 29 | (api/vector-node [(strip-pattern pattern) (api/token-node nil)]) 30 | handlers))})) 31 | 32 | (defn defpaths [{:keys [node]}] 33 | (let [[name & handlers] (rest (:children node))] 34 | {:node (api/list-node 35 | (list 36 | (api/token-node 'def) 37 | name 38 | (api/vector-node (vec handlers))))})) 39 | -------------------------------------------------------------------------------- /src/slash/response.clj: -------------------------------------------------------------------------------- 1 | (ns slash.response 2 | "Definitions and utilities for interaction responses. 3 | 4 | Read https://discord.com/developers/docs/interactions/slash-commands#interaction-response-object") 5 | 6 | (def pong 7 | "The PONG response (type 1)" 8 | {:type 1}) 9 | 10 | (defn channel-message 11 | "Respond to an interaction with a message - `data` is the message object (type 4)." 12 | [data] 13 | {:type 4 14 | :data data}) 15 | 16 | (def deferred-channel-message 17 | "Defer a message response (type 5)" 18 | {:type 5}) 19 | 20 | (def deferred-update-message 21 | "Defer a message update (only for component interactions - type 6)" 22 | {:type 6}) 23 | 24 | (defn update-message 25 | "Update the message - `data` is the message update (only for component interactions - type 7)" 26 | [data] 27 | {:type 7 28 | :data data}) 29 | 30 | (defn autocomplete-result 31 | "Return suggestions for autocompletion (only for autocomplete interactions)." 32 | [choices] 33 | {:type 8 34 | :data {:choices choices}}) 35 | 36 | (defn modal 37 | "Return a [modal](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-modal)." 38 | [title custom-id & components] 39 | {:type 9 40 | :data {:title title 41 | :custom_id custom-id 42 | :components components}}) 43 | 44 | (defn ephemeral 45 | "Takes an interaction response and makes it ephemeral." 46 | [response] 47 | (assoc-in response [:data :flags] 64)) 48 | -------------------------------------------------------------------------------- /test/slash/command/structure_test.clj: -------------------------------------------------------------------------------- 1 | (ns slash.command.structure-test 2 | (:require [slash.command.structure :refer :all] 3 | [clojure.test :refer :all])) 4 | 5 | (deftest command-test 6 | (is (= {:name "foo" 7 | :description "bar" 8 | :options [:baz :quz] 9 | :default_permission false 10 | :default_member_permissions "0"} 11 | (command "foo" "bar" :default-permission false :default-member-permissions "0" :options [:baz :quz])))) 12 | 13 | (deftest sub-command-group-test 14 | (is (= {:type 2 15 | :name "foo" 16 | :description "bar" 17 | :options [:baz :quz]} 18 | (sub-command-group "foo" "bar" :baz :quz)))) 19 | 20 | (deftest sub-command-test 21 | (is (= {:type 1 22 | :name "foo" 23 | :description "bar" 24 | :options [:baz :quz]} 25 | (sub-command "foo" "bar" :options [:baz :quz])))) 26 | 27 | (deftest option-test 28 | (is (= {:type 3 29 | :name "baz" 30 | :description "quz" 31 | :required true 32 | :autocomplete true} 33 | (option "baz" "quz" :string :required true :autocomplete true))) 34 | (is (= {:type 6 35 | :name "baz" 36 | :description "quz" 37 | :choices [:foo :bar]} 38 | (option "baz" "quz" :user :choices [:foo :bar]))) 39 | (is (= {:type 4 40 | :name "baz" 41 | :description "quz" 42 | :min_value -56 43 | :max_value 42} 44 | (option "baz" "quz" :integer :min-value -56 :max-value 42))) 45 | (is (= {:type 7 46 | :name "baz" 47 | :description "quz" 48 | :channel_types [0 2]} 49 | (option "baz" "quz" :channel :channel-types [:guild-text :guild-voice])))) 50 | 51 | (deftest choice-test 52 | (is (= {:name "foo" 53 | :value "bar"} 54 | (choice "foo" "bar"))) 55 | (is (= {:name "baz" 56 | :value 5} 57 | (choice "baz" 5)))) 58 | -------------------------------------------------------------------------------- /src/slash/component/structure.clj: -------------------------------------------------------------------------------- 1 | (ns slash.component.structure 2 | "Functions to make component creation easier. 3 | 4 | Read https://discord.com/developers/docs/interactions/message-components first to understand the structure of message components." 5 | (:require [slash.util :refer [omission-map]])) 6 | 7 | (defn action-row 8 | "Create an action row containing other components. 9 | 10 | See https://discord.com/developers/docs/interactions/message-components#action-rows for more info." 11 | [& components] 12 | {:type 1 13 | :components components}) 14 | 15 | (defn link-button 16 | "Create a button that links to a URL." 17 | [url & {:keys [label emoji disabled]}] 18 | (omission-map 19 | :type 2 20 | :style 5 21 | :url url 22 | :label label 23 | :emoji emoji 24 | :disabled disabled)) 25 | 26 | (def button-styles 27 | "Map of button style names (keywords) to their numerical identifiers." 28 | {:primary 1 29 | :secondary 2 30 | :success 3 31 | :danger 4}) 32 | 33 | (defn button 34 | "Create a regular interaction button. 35 | 36 | See https://discord.com/developers/docs/interactions/message-components#buttons for more info." 37 | [style custom-id & {:keys [label emoji disabled]}] 38 | (omission-map 39 | :type 2 40 | :style (button-styles style) 41 | :custom_id custom-id 42 | :label label 43 | :emoji emoji 44 | :disabled disabled)) 45 | 46 | (defn select-menu 47 | "Create a select menu. 48 | 49 | See https://discord.com/developers/docs/interactions/message-components#select-menus for more info." 50 | [custom-id options & {:keys [placeholder min-values max-values disabled]}] 51 | (omission-map 52 | :type 3 53 | :custom_id custom-id 54 | :options options 55 | :placeholder placeholder 56 | :min_values min-values 57 | :max_values max-values 58 | :disabled disabled)) 59 | 60 | (defn select-option 61 | "Create an option for a select menu." 62 | [label value & {:keys [description emoji default]}] 63 | (omission-map 64 | :label label 65 | :value value 66 | :description description 67 | :emoji emoji 68 | :default default)) 69 | 70 | (def text-input-styles 71 | {:short 1 72 | :paragraph 2}) 73 | 74 | (defn text-input 75 | "Create a text input component for a modal. 76 | 77 | See https://discord.com/developers/docs/interactions/message-components#text-inputs." 78 | [style custom-id label & {:keys [min-length max-length required value placeholder]}] 79 | (omission-map 80 | :type 4 81 | :style (text-input-styles style) 82 | :custom_id custom-id 83 | :label label 84 | :min_length min-length 85 | :max_length max-length 86 | :required required 87 | :value value 88 | :placeholder placeholder)) 89 | -------------------------------------------------------------------------------- /test/slash/component/structure_test.clj: -------------------------------------------------------------------------------- 1 | (ns slash.component.structure-test 2 | (:require [slash.component.structure :refer :all] 3 | [clojure.test :refer :all])) 4 | 5 | 6 | (deftest action-row-test 7 | (is (= {:type 1 8 | :components [1 2 3]} 9 | (action-row 1 2 3)))) 10 | 11 | (deftest link-button-test 12 | (is (= {:type 2 13 | :style 5 14 | :url "http://example.com" 15 | :label "Click me" 16 | :emoji {:name "foo" :id "bar"} 17 | :disabled true} 18 | (link-button 19 | "http://example.com" 20 | :label "Click me" 21 | :emoji {:name "foo" :id "bar"} 22 | :disabled true)))) 23 | 24 | (deftest button-test 25 | (is (= {:type 2 26 | :style 3 27 | :custom_id "xyz" 28 | :label "heya" 29 | :emoji {:name "foo" :id "bar"}} 30 | (button 31 | :success 32 | "xyz" 33 | :label "heya" 34 | :emoji {:name "foo" :id "bar"}))) 35 | (is (= {:type 2 36 | :style 4 37 | :custom_id "abc" 38 | :label "xyz" 39 | :disabled true} 40 | (button 41 | :danger 42 | "abc" 43 | :label "xyz" 44 | :disabled true)))) 45 | 46 | (deftest select-menu-test 47 | (is (= {:type 3 48 | :custom_id "xyz" 49 | :options [1 2 3] 50 | :placeholder "Lorem Ipsum" 51 | :min_values 3} 52 | (select-menu 53 | "xyz" 54 | [1 2 3] 55 | :placeholder "Lorem Ipsum" 56 | :min-values 3))) 57 | (is (= {:type 3 58 | :custom_id "abc" 59 | :options [3 2 1] 60 | :max_values 5 61 | :disabled true} 62 | (select-menu 63 | "abc" 64 | [3 2 1] 65 | :max-values 5 66 | :disabled true)))) 67 | 68 | (deftest select-option-test 69 | (is (= {:label "A" 70 | :value "test" 71 | :description "Lorem Ipsum" 72 | :emoji {:id "foo" :name "bar"}} 73 | (select-option 74 | "A" 75 | "test" 76 | :description "Lorem Ipsum" 77 | :emoji {:id "foo" :name "bar"}))) 78 | (is (= {:label "B" 79 | :value 42 80 | :default true} 81 | (select-option 82 | "B" 83 | 42 84 | :default true)))) 85 | 86 | (deftest text-input-test 87 | (is (= {:type 4 88 | :style 1 89 | :custom_id "xyz" 90 | :label "Enter x" 91 | :value "Hello" 92 | :max_length 50 93 | :required true} 94 | (text-input 95 | :short 96 | "xyz" 97 | "Enter x" 98 | :max-length 50 99 | :required true 100 | :value "Hello"))) 101 | (is (= {:type 4 102 | :style 2 103 | :custom_id "abc" 104 | :label "Enter y" 105 | :placeholder "foo bar" 106 | :min_length 20} 107 | (text-input 108 | :paragraph 109 | "abc" 110 | "Enter y" 111 | :min-length 20 112 | :placeholder "foo bar")))) 113 | -------------------------------------------------------------------------------- /src/slash/command/structure.clj: -------------------------------------------------------------------------------- 1 | (ns slash.command.structure 2 | "Functions to make command definition easier. 3 | 4 | Read https://discord.com/developers/docs/interactions/slash-commands first to understand the structure of slash commands." 5 | (:require [slash.util :refer [omission-map]])) 6 | 7 | (def command-types 8 | "Map of command type names (keywords) to numerical command type identifiers." 9 | {:chat-input 1 10 | :user 2 11 | :message 3}) 12 | 13 | (defn command 14 | "Create a top level command. 15 | 16 | See https://discord.com/developers/docs/interactions/slash-commands#application-command-object-application-command-structure. 17 | `:type` must be one of the keys in [[command-types]], if given." 18 | [name description & {:keys [default-permission default-member-permissions guild-id options type]}] 19 | (omission-map 20 | :name name 21 | :description description 22 | :options options 23 | :guild_id guild-id 24 | :default_permission default-permission 25 | :default_member_permissions default-member-permissions 26 | :type (some-> type command-types))) 27 | 28 | (defn message-command 29 | "Create a top level message command. 30 | 31 | See https://discord.com/developers/docs/interactions/application-commands#message-commands." 32 | [name & {:keys [default-permission default-member-permissions]}] 33 | (command name "" :default-permission default-permission :default_member_permissions default-member-permissions :type :message)) 34 | 35 | (defn user-command 36 | "Create a top level user command. 37 | 38 | See https://discord.com/developers/docs/interactions/application-commands#user-commands." 39 | [name & {:keys [default-permission default-member-permissions]}] 40 | (command name "" :default-permission default-permission :default_member_permissions default-member-permissions :type :user)) 41 | 42 | (defn sub-command-group 43 | "Create a sub command group option." 44 | [name description & sub-commands] 45 | (omission-map 46 | :type 2 47 | :name name 48 | :description description 49 | :options sub-commands)) 50 | 51 | (defn sub-command 52 | "Create a sub command option." 53 | [name description & {:keys [options]}] 54 | (omission-map 55 | :type 1 56 | :name name 57 | :description description 58 | :options options)) 59 | 60 | (def option-types 61 | "Map of option type names (keywords) to their numerical identifiers." 62 | {:string 3 63 | :integer 4 64 | :boolean 5 65 | :user 6 66 | :channel 7 67 | :role 8 68 | :mentionable 9 69 | :number 10 70 | :attachment 11}) 71 | 72 | (def channel-types 73 | "Map of channel type names (keywords) to numerical channel type identifiers." 74 | {:guild-text 0 75 | :dm 1 76 | :guild-voice 2 77 | :group-dm 3 78 | :guild-category 4 79 | :guild-news 5 80 | :guild-store 6 81 | :guild-news-thread 10 82 | :guild-public-thread 11 83 | :guild-private-thread 12 84 | :guild-stage-voice 13}) 85 | 86 | (defn option 87 | "Create a regular option. 88 | 89 | `:channel-types` must be a collection of keys from [[channel-types]], if given. 90 | This may only be set when `type` is `:channel`." 91 | [name description type & {:keys [required choices autocomplete min-value max-value] ch-types :channel-types}] 92 | (omission-map 93 | :type (option-types type type) 94 | :name name 95 | :description description 96 | :required required 97 | :choices choices 98 | :autocomplete autocomplete 99 | :min_value min-value 100 | :max_value max-value 101 | :channel_types (some->> ch-types (mapv channel-types)))) 102 | 103 | (defn choice 104 | "Create an option choice for a choice set." 105 | [name value] 106 | {:name name 107 | :value value}) 108 | -------------------------------------------------------------------------------- /test/slash/command_test.clj: -------------------------------------------------------------------------------- 1 | (ns slash.command-test 2 | (:require [slash.command :refer :all] 3 | [clojure.test :refer :all])) 4 | 5 | (def foo-bar-baz 6 | {:name "foo" 7 | :options 8 | [{:type 2 9 | :name "bar" 10 | :options 11 | [{:type 1 12 | :name "baz" 13 | :options 14 | [{:type 3 15 | :name "hello" 16 | :value "world" 17 | :focused true} 18 | {:type 4 19 | :name "num" 20 | :value 56}]}]}]}) 21 | 22 | (def foo 23 | {:name "foo" 24 | :options 25 | [{:type 5 26 | :name "opt" 27 | :value true} 28 | {:type 7 29 | :name "chan" 30 | :value {:id "123456789"}}]}) 31 | 32 | (def foo-bar 33 | {:name "foo" 34 | :options 35 | [{:type 1 36 | :name "bar"}]}) 37 | 38 | (deftest option-key-test 39 | (is (= "1foo" (option-key "1foo"))) 40 | (is (= "123" (option-key "123"))) 41 | (is (= :crazy-stuff (option-key "crazy-stuff"))) 42 | (is (= :fo1o (option-key "fo1o")))) 43 | 44 | (deftest path-test 45 | (is (= ["foo" "bar" "baz"] (path foo-bar-baz))) 46 | (is (= ["foo"] (path foo))) 47 | (is (= ["foo" "bar"] (path foo-bar)))) 48 | 49 | (deftest option-map-test 50 | (testing "Options exist" 51 | (is (= {:hello "world" :num 56} (option-map foo-bar-baz))) 52 | (is (= {:opt true :chan {:id "123456789"}} (option-map foo)))) 53 | (testing "Options don't exist" 54 | (is (= {} (option-map foo-bar))))) 55 | 56 | (deftest full-option-map-test 57 | (testing "Options exist" 58 | (is (= {:hello {:type 3, :name "hello", :value "world", :focused true}, :num {:type 4, :name "num", :value 56}} 59 | (full-option-map foo-bar-baz))) 60 | (is (= {:opt {:type 5, :name "opt", :value true}, :chan {:type 7, :name "chan", :value {:id "123456789"}}} 61 | (full-option-map foo)))) 62 | (testing "Options don't exist" 63 | (is (= {} (full-option-map foo-bar))))) 64 | 65 | (deftest focused-option-test 66 | (testing "Focused option" 67 | (is (= :hello (focused-option (-> foo-bar-baz :options first :options first :options))))) 68 | (testing "No focused option" 69 | (is (nil? (focused-option (:options foo)))))) 70 | 71 | (deftest option-map-mw-test 72 | (letfn [(handler [{{:keys [option-map full-option-map focused-option]} :data}] 73 | [option-map full-option-map focused-option])] 74 | (is (= [{:hello "world" :num 56} 75 | {:hello {:type 3, :name "hello", :value "world", :focused true}, :num {:type 4, :name "num", :value 56}} 76 | :hello] 77 | ((wrap-options handler) {:data foo-bar-baz}))))) 78 | 79 | (deftest path-mw-test 80 | (letfn [(handler [{{:keys [path]} :data}] path)] 81 | (is (= ["foo" "bar" "baz"] ((wrap-path handler) {:data foo-bar-baz}))))) 82 | 83 | (defn handler-path [handler path] 84 | (-> handler (wrap-check-path path) wrap-path)) 85 | 86 | (defn handler-prefix-path [handler path] 87 | (-> handler (wrap-check-path path :prefix-check? true) wrap-path)) 88 | 89 | (deftest check-path-mw-test 90 | (letfn [(handler [_] true)] 91 | (testing "literal matching" 92 | (is ((handler-path handler ["foo" "bar" "baz"]) {:data foo-bar-baz})) 93 | (is ((handler-path handler ["foo"]) {:data foo})) 94 | (is (not ((handler-path handler ["foo" "bar" "baz"]) {:data foo-bar}))) 95 | (is (not ((handler-path handler ["foo" "quz"]) {:data foo-bar})))) 96 | (testing "placeholder matching" 97 | (is ((handler-path handler ['_]) {:data foo})) 98 | (is ((handler-path handler ["foo" 'sym "baz"]) {:data foo-bar-baz})) 99 | (is (not ((handler-path handler ["foo" '_ '_]) {:data foo-bar})))) 100 | (testing "prefix matching" 101 | (is ((handler-prefix-path handler ["foo" '_]) {:data foo-bar-baz})) 102 | (is ((handler-prefix-path handler ["foo"]) {:data foo})) 103 | (is ((handler-prefix-path handler []) {:data foo-bar})) 104 | (is (not ((handler-prefix-path handler ["foo" "baz"]) {:data foo-bar-baz})))))) 105 | 106 | (def foo-_-baz-handler 107 | (handler ["foo" bar "baz"] all [hello num] 108 | [bar (update all :data dissoc :option-map :full-option-map :focused-option :path) hello num])) 109 | 110 | (def foo-handler 111 | (handler ["foo"] _ {:keys [opt] :as options} 112 | [opt options])) 113 | 114 | (def full-foo-handler 115 | (handler ["foo"] _ ^:full [opt] 116 | opt)) 117 | 118 | (deftest handler-test 119 | (testing "option vector" 120 | (is (nil? (foo-_-baz-handler {:data foo}))) 121 | (is (= ["bar" {:data foo-bar-baz} "world" 56] (foo-_-baz-handler {:data foo-bar-baz})))) 122 | (testing "option binding" 123 | (is (nil? (foo-handler {:data foo-bar}))) 124 | (is (= [true {:opt true :chan {:id "123456789"}}] (foo-handler {:data foo})))) 125 | (testing "full option binding" 126 | (is (= {:type 5, :name "opt", :value true} (full-foo-handler {:data foo}))))) 127 | 128 | (def num-handlers 129 | (mapv handler-path 130 | (map constantly (range)) 131 | [["foo" "nope"] 132 | ["foo" "bar"] 133 | ["foo"] 134 | ["foo" '_] 135 | ["lol"]])) 136 | 137 | (def dispatcher 138 | (partial dispatch num-handlers)) 139 | 140 | (deftest dispatch-test 141 | (is (= 1 (dispatcher {:data foo-bar}))) 142 | (is (= 2 (dispatcher {:data foo}))) 143 | (is (nil? (dispatcher {:data foo-bar-baz})))) 144 | 145 | (def group-handler 146 | (group ["foo" x] 147 | (handler ["baz"] _ _ 148 | x))) 149 | 150 | (deftest group-test 151 | (is (= "bar" (group-handler {:data foo-bar-baz})))) 152 | 153 | (def paths-dispatcher (apply paths num-handlers)) 154 | 155 | (deftest paths-test 156 | (is (= 1 (paths-dispatcher {:data foo-bar}))) 157 | (is (= 2 (paths-dispatcher {:data foo}))) 158 | (is (nil? (paths-dispatcher {:data foo-bar-baz})))) 159 | -------------------------------------------------------------------------------- /src/slash/command.clj: -------------------------------------------------------------------------------- 1 | (ns slash.command 2 | "The command namespace contains functions to create command handlers and dispatch commands." 3 | (:require [linked.core :as linked])) 4 | 5 | (defn path 6 | "Given a command, returns the fully qualified command name as a vector. 7 | 8 | The command is the data associated with an interaction create event of type 2 as Clojure data. 9 | Examples (what the command data represents => what this function returns): 10 | - `/foo bar baz` => [\"foo\" \"bar\" \"baz\"] 11 | - `/foo` => [\"foo\"]" 12 | [{:keys [name options] :as _command}] 13 | (into 14 | [name] 15 | (->> (get options 0 nil) 16 | (iterate (comp #(get % 0 nil) :options)) 17 | (take-while (comp #{1 2} :type)) 18 | (mapv :name)))) 19 | 20 | (defn- actual-command? [layer] 21 | (-> layer :options first :type #{1 2} not)) 22 | 23 | (defn- find-actual-command [command] 24 | (->> command (iterate (comp first :options)) (filter actual-command?) first)) 25 | 26 | (defn option-key 27 | "Turns an option name (string) into an appropriate key representation for a map. 28 | 29 | In practice, this means: strings that start with a digit are returned as-is, everything else is turned into a keyword." 30 | [name] 31 | (some-> name (cond-> (and (seq name) (not (Character/isDigit ^char (first name)))) keyword))) 32 | 33 | (defn option-map 34 | "Returns the options of a command as a map of keywords -> values. 35 | 36 | The command is the data associated with an interaction create event as Clojure data. 37 | 'options' here means the options the user sets, like `baz` in `/foo bar baz: 3`, but not `bar`." 38 | [command] 39 | (into 40 | (linked/map) 41 | (map (juxt (comp option-key :name) :value) (:options (find-actual-command command))))) 42 | 43 | (defn full-option-map 44 | "Returns the options of a command as a map of keywords -> option objects. 45 | 46 | The command is the data associated with an interaction create event as Clojure data. 47 | 'options' here means the options the user sets, like `baz` in `/foo bar baz: 3`, but not `bar`." 48 | [command] 49 | (into 50 | (linked/map) 51 | (map (juxt (comp option-key :name) identity) (:options (find-actual-command command))))) 52 | 53 | (defn focused-option 54 | "Given a list of command options, returns the name of the option that is currently in focus (or `nil` if none is in focus)." 55 | [options] 56 | (->> options (filter :focused) first :name option-key)) 57 | 58 | (defn wrap-options 59 | "Middleware that attaches the following keys to the interaction data (if not already applied): 60 | - `:option-map` (obtained by [[option-map]]) 61 | - `:full-option-map` (obtained by [[full-option-map]]) 62 | - `:focused-option` (name of the focused option if any - obtained by [[focused-option]])" 63 | [handler] 64 | (fn [{command :data :as interaction}] 65 | (handler 66 | (if (contains? command :full-option-map) 67 | interaction 68 | (let [actual-command (find-actual-command command) 69 | full-options (full-option-map actual-command) 70 | focused-option (->> full-options vals focused-option) 71 | options (option-map actual-command)] 72 | (update interaction :data assoc 73 | :option-map options 74 | :full-option-map full-options 75 | :focused-option focused-option)))))) 76 | 77 | (defn wrap-path 78 | "Middleware that attaches the `:path` (obtained by [[path]]) to the command, if not already present." 79 | [handler] 80 | (fn [{command :data :as interaction}] 81 | (handler (cond-> interaction 82 | (not (contains? command :path)) 83 | (assoc-in [:data :path] (path command)))))) 84 | 85 | (defn- paths-match? [pattern actual] 86 | (and 87 | (= (count pattern) (count actual)) 88 | (->> (map vector pattern actual) 89 | (remove (comp symbol? first)) 90 | (map (partial apply =)) 91 | (every? true?)))) 92 | 93 | (defn wrap-check-path 94 | "Middleware that delegates to the handler only if the command `:path` matches the given `path` pattern. 95 | 96 | `path` is a vector of strings (literal matches) and symbols (placeholders that match any value). 97 | Optionally, `:prefix-check? true` can be set, in which case it will only be checked whether `path` prefixes the command path. 98 | 99 | Must run after [[wrap-path]]." 100 | [handler path & {:keys [prefix-check?]}] 101 | (fn [{{actual-path :path} :data :as interaction}] 102 | (when (paths-match? path (cond->> actual-path prefix-check? (take (count path)))) 103 | (handler interaction)))) 104 | 105 | (defn- placeholder-positions 106 | [path] 107 | (->> path 108 | (map-indexed vector) 109 | (filter (comp symbol? second)) 110 | (map first))) 111 | 112 | (defmacro let-placeholders 113 | {:style/indent 2} 114 | [pattern path & body] 115 | (let [placeholder-indices (placeholder-positions pattern) 116 | placeholders (mapv pattern placeholder-indices)] 117 | `(let [~placeholders (map ~path (list ~@placeholder-indices))] 118 | ~@body))) 119 | 120 | (defn- replace-symbols [pattern] 121 | (mapv #(if (symbol? %) ''_ %) pattern)) 122 | 123 | (defmacro handler 124 | "A macro to generate a command handler that will accept commands matching the given pattern. 125 | 126 | `pattern` is a vector of literals (strings) and placeholders (symbols). 127 | Placeholders will match and be bound to any string at that position in the command path. 128 | `interaction-binding` is a binding that will be bound to the entire interaction object. 129 | `options` is either a vector, in which case the symbols in that vector will be bound to the options with corresponding names 130 | - otherwise, it will be bound to the command's [[option-map]] directly. 131 | `body` is the command logic. It may access any of the bound symbols above. 132 | 133 | The function returned by this already has the [[wrap-options]], [[wrap-check-path]] and [[wrap-path]] middlewares applied." 134 | {:style/indent 3} 135 | [pattern interaction-binding options & body] 136 | (let [full? (:full (meta options))] 137 | `(-> (fn [{{option-map# ~(if full? :full-option-map :option-map) path# :path} :data 138 | :as interaction#}] 139 | (let-placeholders ~pattern path# 140 | (let [~interaction-binding interaction# 141 | ~(if (vector? options) `{:keys [~@options]} options) option-map#] 142 | ~@body))) 143 | wrap-options 144 | (wrap-check-path ~(replace-symbols pattern)) 145 | wrap-path))) 146 | 147 | (defmacro defhandler 148 | "Utility macro for `(def my-handler (handler ...))` (see [[handler]])" 149 | {:style/indent 4} 150 | [symbol pattern interaction-binding options & body] 151 | `(def ~symbol (handler ~pattern ~interaction-binding ~options ~@body))) 152 | 153 | (defn dispatch 154 | "A function to dispatch a command to a list of handlers. 155 | 156 | Takes two arguments: `handlers` (a list of command handler functions) and `interaction`, 157 | the interaction of a command execution. 158 | 159 | Each handler will be run until one is found that does not return `nil`." 160 | [handlers interaction] 161 | (loop [[handler & rest] handlers] 162 | (when handler 163 | (if-let [result (handler interaction)] 164 | result 165 | (recur rest))))) 166 | 167 | (defmacro group 168 | "A macro to combine multiple handlers into one under a common prefix. 169 | 170 | `prefix` is a pattern like in [[handler]]. 171 | `handlers` are command handler functions." 172 | {:style/indent 1} 173 | [prefix & handlers] 174 | `(-> (fn [{{path# :path} :data :as interaction#}] 175 | (let-placeholders ~prefix path# 176 | (dispatch (list ~@handlers) (assoc-in interaction# [:data :path] (vec (drop ~(count prefix) path#)))))) 177 | (wrap-check-path ~(replace-symbols prefix) :prefix-check? true) 178 | wrap-path)) 179 | 180 | (defn paths 181 | "Function to combine multiple handlers into one by dispatching on them using [[dispatch]]." 182 | [& handlers] 183 | (-> (partial dispatch handlers) 184 | wrap-path)) 185 | 186 | (defmacro defpaths 187 | "Utility macro for `(def symbol (paths handlers))` (see [[paths]])" 188 | {:style/indent 1} 189 | [symbol & handlers] 190 | `(def ~symbol (paths ~@handlers))) 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slash 2 | 3 | A small Clojure library designed to handle and route Discord interactions, both for gateway events and incoming webhooks. 4 | 5 | slash is environment-agnostic, extensible through middleware and works directly with Clojure data (no JSON parsing/printing included). 6 | 7 | [![Clojars Project](https://img.shields.io/clojars/v/com.github.johnnyjayjay/slash.svg)](https://clojars.org/com.github.johnnyjayjay/slash) 8 | 9 | **slash is currently in a Proof-of-Concept-phase and more features are to be added.**\ 10 | Such features include: 11 | - Add more middleware: argument validation, permission checks, ... 12 | 13 | ## Command Structure Definition 14 | 15 | slash provides utilities to define slash commands in `slash.command.structure`. 16 | 17 | Once you are familiar with [how slash commands are structured](https://discord.com/developers/docs/interactions/application-commands), the functions should be self-explanatory. 18 | 19 | Examples: 20 | 21 | ``` clojure 22 | (require '[slash.command.structure :refer :all]) 23 | 24 | (def input-option (option "input" "Your input" :string :required true)) 25 | 26 | (def echo-command 27 | (command 28 | "echo" 29 | "Echoes your input" 30 | :options 31 | [input-option])) 32 | 33 | (def fun-commands 34 | (command 35 | "fun" 36 | "Fun commands" 37 | :options 38 | [(sub-command 39 | "reverse" 40 | "Reverse the input" 41 | :options 42 | [input-option 43 | (option "words" "Reverse words instead of characters?" :boolean)]) 44 | (sub-command 45 | "mock" 46 | "Spongebob-mock the input" 47 | :options 48 | [input-option])])) 49 | ``` 50 | 51 | ## Component Structure Definition 52 | 53 | slash also provides similar utilities to create [message components](https://discord.com/developers/docs/interactions/message-components). 54 | 55 | Examples: 56 | 57 | ``` clojure 58 | (require '[slash.component.structure :refer :all]) 59 | 60 | (def my-components 61 | [(action-row 62 | (button :danger "unsubscribe" :label "Turn notifications off") 63 | (button :success "subcribe" :label "Turn notifications on")) 64 | (action-row 65 | (select-menu 66 | "language" 67 | [(select-option "English" "EN" :emoji {:name "🇬🇧"}) 68 | (select-option "French" "FR" :emoji {:name "🇫🇷"}) 69 | (select-option "Spanish" "ES" :emoji {:name "🇪🇸"})] 70 | :placeholder "Language"))]) 71 | ``` 72 | 73 | ## Routing 74 | 75 | You can use slash to handle interaction events based on their type. 76 | 77 | ``` clojure 78 | (slash.core/route-interaction handler-map interaction-event) 79 | ``` 80 | 81 | `handler-map` is a map containing handlers for the different types of interactions that may occur. E.g. 82 | 83 | ``` clojure 84 | {:ping ping-handler 85 | :application-command command-handler 86 | :message-component component-handler} 87 | ``` 88 | 89 | You can find default handler maps for both gateway and webhook environments in `slash.gateway`/`slash.webhook` respectively. 90 | 91 | ### Commands 92 | 93 | slash offers further routing middleware and utilities specifically for slash commands. The API is heavily inspired by [compojure](https://github.com/weavejester/compojure). 94 | 95 | Simple, single-command example: 96 | 97 | ``` clojure 98 | (require '[slash.command :as cmd] 99 | '[slash.response :as rsp :refer [channel-message ephemeral]]) ; The response namespace provides utility functions to create interaction responses 100 | 101 | (cmd/defhandler echo-handler 102 | ["echo"] ; Command path 103 | _interaction ; Interaction binding - whatever you put here will be bound to the entire interaction 104 | [input] ; Command options - can be either a vector or a custom binding (symbol, map destructuring, ...) 105 | (channel-message {:content input})) 106 | ``` 107 | 108 | You can now use `echo-handler` as a command handler to call with a command interaction event and it will return the response if it is an `echo` command or `nil` if it's not. 109 | 110 | An example with multiple (sub-)commands: 111 | 112 | ``` clojure 113 | (require '[clojure.string :as str]) 114 | 115 | (cmd/defhandler reverse-handler 116 | ["reverse"] 117 | _ 118 | [input words] 119 | (channel-message 120 | {:content (if words 121 | (->> #"\s+" (str/split input) reverse (str/join " ")) 122 | (str/reverse input))})) 123 | 124 | (cmd/defhandler mock-handler 125 | ["mock"] 126 | _ 127 | [input] 128 | (channel-message 129 | {:content (->> input 130 | (str/lower-case) 131 | (map #(cond-> % (rand-nth [true false]) Character/toUpperCase)) 132 | str/join)})) 133 | 134 | (cmd/defhandler unknown-handler 135 | [unknown] ; Placeholders can be used in paths too 136 | {{{user-id :id} :user} :member} ; Using the interaction binding to get the user who ran the command 137 | _ ; no options 138 | (-> (channel-message {:content (str "I don't know the command `" unknown "`, <@" user-id ">.")}) 139 | ephemeral)) 140 | 141 | (cmd/defpaths command-paths 142 | (cmd/group ["fun"] ; common prefix for all following commands 143 | reverse-handler 144 | mock-hander 145 | unknown-handler)) 146 | ``` 147 | 148 | Similar to the previous example, `command-paths` can now be used as a command handler. It will call each of its nested handlers with the interaction and stop once a handler is found that does not return `nil`. 149 | 150 | ### Autocomplete 151 | 152 | You can also use the command routing facilities to provide autocomplete for your commands. 153 | 154 | ``` clojure 155 | ;; Will produce autocompletion for command `/foo bar` on option `baz`, using the partial value of `baz` in the process 156 | (cmd/defhandler foo-bar-autocompleter 157 | ["foo" "bar"] 158 | {{:keys [focused-option]} :data} 159 | [baz] 160 | (case focused-option 161 | :baz (rsp/autocomplete-result (map (partial str baz) [1 2 3])))) 162 | ``` 163 | 164 | ### Full Webhook Example 165 | 166 | For this example, I use the ring webserver specification. 167 | 168 | Using [ring-json](https://github.com/ring-clojure/ring-json) and [ring-discord-auth](https://github.com/JohnnyJayJay/ring-discord-auth) we can create a ring handler for accepting outgoing webhooks. 169 | 170 | ``` clojure 171 | (require '[slash.webhook :refer [webhook-defaults]] 172 | '[ring-discord-auth.ring :refer [wrap-authenticate]] 173 | '[ring.middleware.json :refer [wrap-json-body wrap-json-response]]) 174 | 175 | (def ring-handler 176 | (-> (partial slash.core/route-interaction 177 | (assoc webhook-defaults :application-command command-paths)) 178 | wrap-json-response 179 | (wrap-json-body {:keyword? true}) 180 | (wrap-authenticate "application public key"))) 181 | ``` 182 | 183 | ### Full Gateway Example 184 | 185 | For this example, I use [discljord](https://github.com/IGJoshua/discljord). 186 | 187 | You also see the use of the `wrap-response-return` middleware for the interaction handler, which allows you to simply return the interaction 188 | responses from your handlers and let the middleware respond via REST. You only need to provide a callback that specifies how to respond to the interaction (as I'm using discljord here, I used its functions for this purpose). 189 | 190 | ``` clojure 191 | (require '[discljord.messaging :as rest] 192 | '[discljord.connections :as gateway] 193 | '[discljord.events :as events] 194 | '[clojure.core.async :as a] 195 | '[slash.gateway :refer [gateway-defaults wrap-response-return]]) 196 | 197 | (let [rest-conn (rest/start-connection! "bot token") 198 | event-channel (a/chan 100) 199 | gateway-conn (gateway/connect-bot! "bot token" event-channel :intents #{}) 200 | event-handler (-> slash.core/route-interaction 201 | (partial (assoc gateway-defaults :application-command command-paths)) 202 | (wrap-response-return (fn [id token {:keys [type data]}] 203 | (rest/create-interaction-response! rest-conn id token type :data data))))] 204 | (events/message-pump! event-channel (partial events/dispatch-handlers {:interaction-create [#(event-handler %2)]}))) 205 | ``` 206 | This is a very quick and dirty example. More in-depth documentation and tutorials will follow soon. 207 | 208 | ## clj-kondo support for macros 209 | 210 | You can find a clj-kondo config that gets rid of "unresolved symbol" warnings in [.clj-kondo/](./.clj-kondo). Just copy [the hooks](./.clj-kondo/hooks) to your clj-kondo config folder (preserving the directory structure, of course!) and add this to your `config.edn`: 211 | 212 | ``` clojure 213 | {:hooks {:analyze-call {slash.command/handler hooks.slash/handler 214 | slash.command/defhandler hooks.slash/defhandler 215 | slash.command/group hooks.slash/group 216 | slash.command/defpaths hooks.slash/defpaths}}} 217 | ``` 218 | 219 | ## License 220 | 221 | Copyright © 2021-2023 JohnnyJayJay 222 | 223 | Licensed under the MIT license. 224 | --------------------------------------------------------------------------------