mirror of
https://github.com/kiranshila/Doplarr.git
synced 2026-07-24 22:33:36 -04:00
Moved all the interaction flow to a multimethod-based state machine
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}]
|
||||
|
||||
@@ -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 []})))))))))
|
||||
@@ -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)))
|
||||
|
||||
132
src/doplarr/interaction_state_machine.clj
Normal file
132
src/doplarr/interaction_state_machine.clj
Normal 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))))))
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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)))))))))
|
||||
Reference in New Issue
Block a user