Moved all the interaction flow to a multimethod-based state machine

This commit is contained in:
Kiran Shila
2021-09-19 11:44:03 -07:00
parent 4eb9b48e27
commit 4232a82ca8
7 changed files with 178 additions and 202 deletions

View File

@@ -7,7 +7,7 @@
(defn fatal-error [ex]
(log/fatal ex)
(System/exit -1))
#_(System/exit -1))
(defn deep-merge [a & maps]
(if (map? a)

View File

@@ -1,9 +1,8 @@
(ns doplarr.core
(:require
[doplarr.discord :as discord]
[doplarr.config :as config]
[doplarr.direct :as direct]
[doplarr.proxied :as proxied]
[doplarr.interaction-state-machine :as ism]
[doplarr.discord :as discord]
[discljord.messaging :as m]
[discljord.connections :as c]
[discljord.events :as e]
@@ -11,11 +10,6 @@
[clojure.core.async :as a])
(:gen-class))
(def backend (delay (config/backend)))
(def backend-request {:direct #'doplarr.direct/make-request
:proxied #'doplarr.proxied/make-request})
;;;;;;;;;;;;;;;;;;;;;;;; Gateway event handlers
(defmulti handle-event
(fn [event-type _]
@@ -25,8 +19,8 @@
[_ data]
(let [interaction (discord/interaction-data data)]
(case (:type interaction)
:application-command ((backend-request @backend) interaction) ; These will all be requests as that is the only top level command
:message-component (discord/continue-request interaction))))
:application-command (ism/start-interaction interaction)
:message-component (ism/continue-interaction interaction))))
(defmethod handle-event :ready
[_ {{id :id} :user}]

View File

@@ -1,68 +0,0 @@
(ns doplarr.direct
(:require
[doplarr.discord :as discord]
[doplarr.sonarr :as sonarr]
[doplarr.radarr :as radarr]
[com.rpl.specter :as s]
[clojure.core.async :as a]))
(def search-fn {:series sonarr/search
:movie radarr/search})
(def profiles-fn {:series sonarr/quality-profiles
:movie radarr/quality-profiles})
(def request-fn {:series sonarr/request
:movie radarr/request})
(defn select-profile [profiles uuid]
(discord/dropdown "Which quality profile?"
(str "select_profile:" uuid)
(map #(hash-map :label (:name %) :value (:id %)) profiles)))
(defn make-request [interaction]
(let [uuid (str (java.util.UUID/randomUUID))
id (:id interaction)
token (:token interaction)
search (:options (:payload interaction))
request-type (first (keys search))
request-term (s/select-one [request-type :term] search)
chan (a/chan)]
; Send the in-progress response
(discord/interaction-response id token 5 :ephemeral? true)
; Create this command's channel
(swap! discord/cache assoc uuid chan)
(a/go
(let [results (->> ((search-fn request-type) request-term)
a/<!
(into [] (take @discord/max-results)))]
; Results selection
(a/<! (discord/update-interaction-response token (discord/search-response results uuid)))
(when-some [selection-interaction (a/<! (discord/await-interaction chan token))]
(let [selection-id (Integer/parseInt (s/select-one [:payload :values 0] selection-interaction))
profiles (->> ((profiles-fn request-type))
a/<!
(into []))]
; Profile selection
(a/<! (discord/update-interaction-response token (select-profile profiles uuid)))
(when-some [profile-interaction (a/<! (discord/await-interaction chan token))]
(let [selection (nth results selection-id)
profile-id (Integer/parseInt (s/select-one [:payload :values 0] profile-interaction))
profile (s/select-one [s/ALL (comp (partial = profile-id) :id) :name] profiles)
season-id (when (= request-type :series)
; Optional season selection for TV shows
(a/<! (discord/update-interaction-response token (discord/select-season selection uuid)))
(when-some [season-interaction (a/<! (discord/await-interaction chan token))]
(Integer/parseInt (s/select-one [:payload :values 0] season-interaction))))]
; Verify request
(a/<! (discord/update-interaction-response token (discord/request selection uuid :season season-id :profile profile)))
; Wait for the button press, we don't care about the actual interaction
(a/<! (discord/await-interaction chan token))
; Send public followup and actually perform request
(a/<! (discord/followup-repsonse token (discord/request-alert selection :season season-id :profile profile)))
((request-fn request-type)
selection
{:season season-id
:profile-id profile-id})
(discord/update-interaction-response token {:content "Requested!"
:components []})))))))))

View File

@@ -3,9 +3,7 @@
[config.core :refer [env]]
[discljord.messaging :as m]
[clojure.core.cache.wrapped :as cache]
[clojure.core.async :as a]
[com.rpl.specter :as s]
[clojure.string :as str]))
[com.rpl.specter :as s]))
(defonce state (atom nil))
(defonce cache (cache/ttl-cache-factory {} :ttl 900000)) ; 15 Minute cache expiration, coinciding with the interaction token
@@ -14,12 +12,12 @@
(def request-command
{:name "request"
:description "Requests a series or movie"
:description "Request a series or movie"
:default_permission false
:options
[{:type 1
:name "series"
:description "Requests a series"
:description "Request a series"
:options
[{:type 3
:name "term"
@@ -27,15 +25,16 @@
:required true}]}
{:type 1
:name "movie"
:description "Requests a movie",
:description "Request a movie",
:options
[{:type 3
:name "term"
:description "Search term"
:required true}]}]})
(def timed-out-response {:content "Request timed out, please try again"
:components []})
(defn content-response [content]
{:content content
:components []})
(def interaction-types {1 :ping
2 :application-command
@@ -148,9 +147,14 @@
(if (empty? results)
{:content "Search result returned no hits"}
(dropdown "Choose one of the following results"
(str "select:" uuid)
(str "result-select:" uuid)
(map-indexed select-menu-option results))))
(defn select-profile [profiles uuid]
(dropdown "Which quality profile?"
(str "profile-select:" uuid)
(map #(hash-map :label (:name %) :value (:id %)) profiles)))
(defn selection-embed [selection & {:keys [season profile]}]
{:title (:title selection)
:description (:overview selection)
@@ -180,18 +184,10 @@
(defn select-season [series uuid]
(dropdown "Which season?"
(str "select_season:" uuid)
(str "season-select:" uuid)
(conj (map #(hash-map :label (str "Season: " %) :value %)
(range 1 (inc (:seasonCount series))))
{:label "All Seasons" :value "-1"})))
(defn await-interaction [chan token]
(a/go
(a/alt!
(a/timeout channel-timeout) (ex-info "Interaction timed out" {:token token})
chan ([v] v))))
(defn continue-request [interaction]
(let [[_ uuid] (str/split (s/select-one [:payload :component-id] interaction) #":")]
(interaction-response (:id interaction) (:token interaction) 6)
(a/offer! (get @cache uuid) interaction)))
(defn dropdown-index [interaction]
(Integer/parseInt (s/select-one [:payload :values 0] interaction)))

View File

@@ -0,0 +1,132 @@
(ns doplarr.interaction-state-machine
(:require
[doplarr.overseerr :as ovsr]
[doplarr.discord :as discord]
[clojure.core.async :as a]
[com.rpl.specter :as s]
[fmnoise.flow :as flow :refer [then else]]
[clojure.string :as str]
[doplarr.config :as config]
[doplarr.sonarr :as sonarr]
[doplarr.radarr :as radarr]))
(def backend (delay (config/backend)))
(def search-fn {:overseerr {:series ovsr/search-series
:movie ovsr/search-movie}
:direct {:series sonarr/search
:movie radarr/search}})
(def profiles-fn {:overseerr {:series #(a/go nil)
:movie #(a/go nil)}
:direct {:series sonarr/quality-profiles
:movie radarr/quality-profiles}})
(def process-selection-fn {:overseerr ovsr/post-process-selection
:direct #(a/go (identity %))})
(def request-selection-fn {:overseerr ovsr/selection-to-request
:direct identity})
(def request-fn {:overseerr {:series ovsr/request
:movie ovsr/request}
:direct {:series sonarr/request
:movie radarr/request}})
(def content-status-fn {:overseerr {:series ovsr/selection-status
:movie ovsr/selection-status}
:direct {:series sonarr/started-aquisition?
:movie (constantly nil)}}) ;FIXME
(defn start-interaction [interaction]
(let [uuid (str (java.util.UUID/randomUUID))
id (:id interaction)
token (:token interaction)
payload-opts (:options (:payload interaction))
request-type (first (keys payload-opts))
query (s/select-one [request-type :term] payload-opts)]
(discord/interaction-response id token 5 :ephemeral? true)
(a/go
(let [results (->> (a/<! (((search-fn @backend) request-type) query))
(into [] (take @discord/max-results)))]
(swap! discord/cache assoc uuid {:results results
:request-type request-type
:token token
:last-modified (System/currentTimeMillis)})
(discord/update-interaction-response token (discord/search-response results uuid))))))
(defmulti process-event (fn [event _ _] event))
(defmethod process-event "result-select" [_ interaction uuid]
(a/go
(let [{:keys [results request-type token]} (get @discord/cache uuid)
selection-id (discord/dropdown-index interaction)
profiles (->> (a/<! (((profiles-fn @backend) request-type)))
(into []))
selection (a/<! ((process-selection-fn @backend) (nth results selection-id)))]
(case request-type
:series (discord/update-interaction-response token (discord/select-season selection uuid))
:movie (case @backend
:overseerr (discord/update-interaction-response token (discord/request selection uuid))
:direct (discord/update-interaction-response token (discord/select-profile profiles uuid))))
(swap! discord/cache assoc-in [uuid :profiles] profiles)
(swap! discord/cache assoc-in [uuid :selection] selection))))
(defmethod process-event "season-select" [_ interaction uuid]
(let [{:keys [token selection profiles]} (get @discord/cache uuid)
season (discord/dropdown-index interaction)]
(case @backend
:overseerr (discord/update-interaction-response token (discord/request selection uuid :season season))
:direct (discord/update-interaction-response token (discord/select-profile profiles uuid)))
(swap! discord/cache assoc-in [uuid :season] season)))
(defmethod process-event "profile-select" [_ interaction uuid]
(let [{:keys [token profiles season selection]} (get @discord/cache uuid)
profile-id (discord/dropdown-index interaction)
profile (s/select-one [s/ALL (comp (partial = profile-id) :id) :name] profiles)]
(discord/update-interaction-response token (discord/request selection uuid :season season :profile profile))
(swap! discord/cache assoc-in [uuid :profile-id] profile-id)))
(defmethod process-event "request" [_ interaction uuid]
(a/go
(let [{:keys [token selection season profile request-type profile-id is4k]} (get @discord/cache uuid)
user-id (:user-id interaction)
ovsr-id ((a/<! (ovsr/discord-users)) user-id)]
(if (nil? ovsr-id)
(discord/update-interaction-response token (discord/content-response "You do not have an associated account on Overseerr"))
(case (((content-status-fn @backend) request-type) selection :season season :is4k is4k)
:pending (discord/update-interaction-response token (discord/content-response "This has been requested, and the request is pending."))
:processing (discord/update-interaction-response token (discord/content-response "This is currently processing and should be available soon."))
:available (discord/update-interaction-response token (discord/content-response "This is already available!"))
(->> (a/<! (((request-fn @backend) request-type)
((request-selection-fn @backend) selection :season season :is4k (boolean is4k))
:ovsr-id ovsr-id
:profile-id profile-id))
(then (fn [_]
(discord/update-interaction-response token (discord/content-response "Requested!"))
(discord/followup-repsonse token (discord/request-alert selection :season season :profile profile))))
(else (fn [e]
(let [{:keys [status body] :as data} (ex-data e)
msg (second (re-matches #"\{\"message\":\"(.+)\"\}" body))] ; Not sure why this JSON didn't get parsed
(cond
(= status 403) (discord/update-interaction-response token (discord/content-response msg))
:else (throw (ex-info "Non 403 error on request" data))))))))))))
(defmethod process-event "request-4k" [_ interaction uuid]
(swap! discord/cache assoc-in [uuid :is4k] true)
(process-event "request" interaction uuid))
(defn continue-interaction [interaction]
(let [[event uuid] (str/split (s/select-one [:payload :component-id] interaction) #":")
now (System/currentTimeMillis)]
; Send the ack
(discord/interaction-response (:id interaction) (:token interaction) 6)
; Check last modified
(let [{:keys [token last-modified]} (get @discord/cache uuid)]
(if (> (- now last-modified) discord/channel-timeout)
; Update interaction with timeout message
(discord/update-interaction-response token (discord/content-response "Request times out, please try again."))
; Move through the state machine to update cache side effecting new components
(do
(swap! discord/cache assoc-in [uuid :last-modified] now)
(process-event event interaction uuid))))))

View File

@@ -11,6 +11,8 @@
(def poster-path "https://image.tmdb.org/t/p/w500")
(def status [:unknown :pending :processing :partially-available :available])
(defn GET [endpoint & [params]]
(utils/http-request
:get
@@ -83,31 +85,27 @@
(defn search-series [term]
(search term "tv"))
(defn details [id media-type]
(a/go
(->> (a/<! (GET (str (if (= "tv" media-type)
"/tv/"
"/movie/")
id)))
(then :body)
(else utils/fatal-error))))
(defn details
([selection] (details (:id selection) (:mediaType selection)))
([id media-type]
(a/go
(->> (a/<! (GET (str "/" media-type "/" id)))
(then :body)
(else utils/fatal-error)))))
(defn season-requested? [selection season & {:keys [is4k]}]
(defn season-status [selection season & {:keys [is4k]}]
(when-let [info (:mediaInfo selection)]
; Seasons exist, check if the selected season exists
(let [seasons (:seasons info)]
(if (empty? seasons)
true
(= 5 ((if is4k :status4k :status) (nth seasons (dec season))))))))
(when-let [[& seasons] (:seasons info)]
(status (dec ((if is4k :status4k :status) (nth seasons (dec season))))))))
(defn movie-requested? [selection & {:keys [is4k]}]
(defn movie-status [selection & {:keys [is4k]}]
(when-let [info (:mediaInfo selection)]
(= 5 ((if is4k :status4k :status) info))))
(status (dec ((if is4k :status4k :status) info)))))
(defn selection-requested? [selection & {:keys [season is4k]}]
(defn selection-status [selection & {:keys [season is4k]}]
(case (:mediaType selection)
"tv" (season-requested? selection season :is4k is4k)
"movie" (movie-requested? selection :is4k is4k)))
"tv" (season-status selection season :is4k is4k)
"movie" (movie-status selection :is4k is4k)))
(defn selection-to-request [selection & {:keys [season is4k]}]
(cond-> {:mediaType (:mediaType selection)
@@ -123,9 +121,15 @@
(assoc s :description (:overview s))
(assoc s :remotePoster (str poster-path (:posterPath s)))))
(defn request [body user-id]
(defn post-process-selection [selection]
(a/go
(let [details (a/<! (details selection))
fourK-backend? (a/<! (backend-4k? (:mediaType selection)))]
(selection-to-embedable (merge details selection {:backend-4k fourK-backend?})))))
(defn request [body & {:keys [ovsr-id]}]
(a/go
(->> (a/<! (POST "/request" {:form-params body
:content-type :json
:headers {"X-API-User" (str user-id)}}))
:headers {"X-API-User" (str ovsr-id)}}))
(then (constantly nil)))))

View File

@@ -1,82 +0,0 @@
(ns doplarr.proxied
(:require
[doplarr.discord :as discord]
[doplarr.overseerr :as ovsr]
[clojure.core.async :as a]
[fmnoise.flow :as flow :refer [then else flet]]
[com.rpl.specter :as s]
[clojure.string :as str]))
(def search-fn {:series ovsr/search-series
:movie ovsr/search-movie})
(defn timeout-bail [token error]
(discord/update-interaction-response token discord/timed-out-response)
error)
(defn request-response [content]
{:content content
:components []})
(def missing-user-response {:content "You do not have an associated account on Overseerr"
:components []})
(def request-exists-response {:content "This has already been requested"
:components []})
(defn make-request [interaction]
(let [uuid (str (java.util.UUID/randomUUID))
id (:id interaction)
user-id (:user-id interaction)
token (:token interaction)
search (:options (:payload interaction))
request-type (first (keys search))
request-term (s/select-one [request-type :term] search)
chan (a/chan)]
; Send the in-progress response
(discord/interaction-response id token 5 :ephemeral? true)
; Create this command's channel
(swap! discord/cache assoc uuid chan)
(a/go
(let [results (->> ((search-fn request-type) request-term)
a/<!
(into [] (take @discord/max-results)))]
; Results selection
(a/<! (discord/update-interaction-response token (discord/search-response results uuid)))
; Wait for selection
(flet [selection-interaction (->> (a/<! (discord/await-interaction chan token))
(else timeout-bail))]
(let [selection-id (Integer/parseInt (s/select-one [:payload :values 0] selection-interaction))
selection-raw (nth results selection-id)
details (a/<! (ovsr/details (:id selection-raw) (:mediaType selection-raw)))
fourK-backend? (a/<! (ovsr/backend-4k? (:mediaType selection-raw)))
selection (ovsr/selection-to-embedable (merge details selection-raw {:backend-4k fourK-backend?}))
season-id (when (= request-type :series)
; Optional season selection for TV shows
(a/<! (discord/update-interaction-response token (discord/select-season selection uuid)))
(flet [season-interaction (->> (a/<! (discord/await-interaction chan token))
(else timeout-bail))]
(Integer/parseInt (s/select-one [:payload :values 0] season-interaction))))]
; Verify request or display that it has been requested
(a/<! (discord/update-interaction-response token (discord/request selection uuid :season season-id)))
; Wait for the button press
(flet [request-interaction (->> (a/<! (discord/await-interaction chan token))
(else timeout-bail))
request-4k? (str/starts-with? (s/select-one [:payload :component-id] request-interaction) "request-4k")]
; Send public followup and actually perform request if the user exists
(if-let [ovsr-id ((a/<! (ovsr/discord-users)) user-id)]
(if (ovsr/selection-requested? selection :season season-id)
(a/<! (discord/update-interaction-response token request-exists-response))
(flet [_ (->> (a/<! (ovsr/request
(ovsr/selection-to-request selection :season season-id :is4k request-4k?)
ovsr-id))
(then (fn [_] (discord/update-interaction-response token {:content "Requested!"
:components []})))
(else (fn [e]
(let [{:keys [status body] :as data} (ex-data e)
msg (second (re-matches #"\{\"message\":\"(.+)\"\}" body))] ; Not sure why this JSON didn't get parsed
(if (= status 403)
(a/<! (discord/update-interaction-response token (request-response msg)))
(throw (ex-info "Non 403 error on request" data)))))))]
(a/<! (discord/followup-repsonse token (discord/request-alert selection :season season-id)))))
(discord/update-interaction-response token missing-user-response)))))))))