diff --git a/src/yetibot/core/adapters/adapter.clj b/src/yetibot/core/adapters/adapter.clj index ec47cc27..bba5bbea 100644 --- a/src/yetibot/core/adapters/adapter.clj +++ b/src/yetibot/core/adapters/adapter.clj @@ -6,8 +6,63 @@ (defprotocol Adapter + ;; TODO implement this in IRC and Slack adapters, then call it from the + ;; respective functions in each adapter that handle incoming messages. + (resolve-users + [_ event] + "Given some kind of event, figure out if users are involved and resolve them + (where resolve means look them up in the DB, creating them on demand if + they don't already exist). + + Users are accreted lazily over time in Yetibot as they are: + + - observed because they authored a message + - mentioned in another user's message + - found to be in a channel after a user asked for the members of a channel + - possibly other ways we haven't thought about yet + + Note: there is no standard notion of events shared between adapters and + there doesn't necessarily need to be, since the event is handled completely + internally by each adapter. + + Types of events and corresponding users could include: + + 1. Incoming message + - An incoming message has an author + - A message may mention a user or users. In IRC this could rely on a + convention of @username style mentions. In Slack, it's explicit since + Slack encodes users. + + 2. Channel join or leave + - This is a more eager resolution of users - probably not worth doing in + the initial pass, but listed here as an example of the kind of thing we + *could* do. + + For each detected user call `resolve-user!` to ensure it's persisted and + canonicalized. + + Returns a map of {id user}. + ") + + ;; TODO implement + (resolve-user! [_ adapter-user] + "Takes a user in the shape that a specific adapter provides and + canonicalizes and persists (if not already). + + See yetibot.core.models.users/create-user for the existing attempt to do + this (in memory representation only - no persistence). + + Returns the canonicalized user.") + + ;; TODO implement in all adapters + (users [_ channel] + "Given a channel, figure out which users are in the channel and resolve all + of them via `resolve-user!`. + + Returns a map of {id user}") + (uuid [_] "A UUID that represents an instance, represented in config by the - :name key") + :name key") (platform-name [_] "String describing the chat platform this adapter supports.") @@ -18,12 +73,12 @@ (send-msg [_ msg] "Single message to post") (join [_ channel] - "join channel - may not be supported by all adapters, e.g. Slack. In this + "join channel - may not be supported by all adapters, e.g. Slack. In this case the adapter should return instructions for its method of joining (e.g. /invite in Slack).") (leave [_ channel] "leave channel - may not be supported - should give instructions - just like join.") + just like join.") (chat-source [_ channel] "Define a chat-source map specific to this adapter") diff --git a/src/yetibot/core/adapters/irc.clj b/src/yetibot/core/adapters/irc.clj index a3f4a75e..66ea56bf 100644 --- a/src/yetibot/core/adapters/irc.clj +++ b/src/yetibot/core/adapters/irc.clj @@ -119,18 +119,21 @@ "Recieve and handle messages from IRC. This can either be in channels yetibot is listening in, or it can be a private message. If yetibot does not recognize the :target, reply back to user with PRIVMSG." - [a irc info] - (log/info "handle-message" (pr-str info)) - (let [config (:config a) - {yetibot-nick :nick} @irc - yetibot-user (construct-yetibot-from-nick yetibot-nick) - user-id (:user info) - chan (or (recognized-chan? a (:target info)) (:nick info)) - user (users/get-user (chat-source chan) user-id)] - (log/info "handle message" info "from" chan yetibot-user) - (binding [*target* chan] - (handle-raw (chat-source chan) - user :message yetibot-user {:body (:text info)})))) + [a irc info] + (log/info "handle-message" (pr-str info)) + ;; TODO - call (a/resolve-users info) to ensure users are resolved, then + ;; pass the resulting map along to `handle-raw` below in addition to `:body`. + ;; Maybe we call it :resolved-users? + (let [config (:config a) + {yetibot-nick :nick} @irc + yetibot-user (construct-yetibot-from-nick yetibot-nick) + user-id (:user info) + chan (or (recognized-chan? a (:target info)) (:nick info)) + user (users/get-user (chat-source chan) user-id)] + (log/info "handle message" info "from" chan yetibot-user) + (binding [*target* chan] + (handle-raw (chat-source chan) + user :message yetibot-user {:body (:text info)})))) (defn handle-part "Event that fires when someone leaves a channel that Yetibot is listening in" diff --git a/src/yetibot/core/adapters/slack.clj b/src/yetibot/core/adapters/slack.clj index 193c7564..93be9c32 100644 --- a/src/yetibot/core/adapters/slack.clj +++ b/src/yetibot/core/adapters/slack.clj @@ -234,6 +234,9 @@ yetibot? (= yetibot-uid (:user event)) user-model (assoc (users/get-user cs (:user event)) :yetibot? yetibot?) + ;; TODO - call (a/resolve-users event) to ensure users are resolved, + ;; then pass the resulting map along to `handle-raw` as + ;; `:resolved-users` below in addition to `:body`. body (if (s/blank? (:text event)) ;; if text is blank attempt to read an attachment fallback (->> event :attachments (map :text) (s/join \newline)) diff --git a/src/yetibot/core/commands/alias.clj b/src/yetibot/core/commands/alias.clj index 8f9a2392..1d73bdee 100644 --- a/src/yetibot/core/commands/alias.clj +++ b/src/yetibot/core/commands/alias.clj @@ -22,7 +22,8 @@ ;; evaluation of piped expressions (i.e. %s instead of $s) expr (binding [*subst-prefix* method-like-replacement-prefix] (str command/config-prefix (pseudo-format-n cmd args))) - results (record-and-run-raw expr user yetibot-user + resolved-users nil ;; TODO + results (record-and-run-raw expr user resolved-users yetibot-user ;; avoid double recording the yetibot ;; response since the parent command ;; execution that evaluated the alias diff --git a/src/yetibot/core/commands/cron.clj b/src/yetibot/core/commands/cron.clj index 04e47bff..7fbdc5ef 100644 --- a/src/yetibot/core/commands/cron.clj +++ b/src/yetibot/core/commands/cron.clj @@ -42,7 +42,7 @@ *adapter-uuid* (read-string chat-source-adapter)] (let [expr (str command/config-prefix cmd) [{:keys [error? timeout? result] :as expr-result}] - (record-and-run-raw expr nil nil)] + (record-and-run-raw expr nil nil nil)] (info "cron result" (pr-str expr-result)) (if (and result (not error?) (not timeout?)) (chat-data-structure result) diff --git a/src/yetibot/core/commands/observe.clj b/src/yetibot/core/commands/observe.clj index 2e4e1a78..aca14645 100644 --- a/src/yetibot/core/commands/observe.clj +++ b/src/yetibot/core/commands/observe.clj @@ -103,8 +103,9 @@ ;; username and channel name with no explicit ;; piping behavior rendered-cmd)) + resolved-users nil ;; TODO [{:keys [error? timeout? result] :as expr-result}] - (record-and-run-raw expr user yetibot-user)] + (record-and-run-raw expr user resolved-users yetibot-user)] (if (and result (not error?) (not timeout?)) (chat-data-structure result) (info "Skipping observer because it errored or timed out" diff --git a/src/yetibot/core/db/user.clj b/src/yetibot/core/db/user.clj new file mode 100644 index 00000000..51838931 --- /dev/null +++ b/src/yetibot/core/db/user.clj @@ -0,0 +1,22 @@ +(ns yetibot.core.db.user + (:require + [yetibot.core.db.util :as db.util])) + +(def schema {:schema/table "user" + :schema/specs + (into [[:chat-source-adapter :text] + [:user-id :text "NOT NULL"] + [:username :text "NOT NULL"] + [:display-name :text "NOT NULL"] + ;; should we attempt to keep track of which channels the + ;; user is in? 🤔 + ;; if we wanted to store it as a column here it's have to be + ;; some serialized form of a Clojure collection + ] + (db.util/default-fields))}) + +(def create (partial db.util/create (:schema/table schema))) + +(def query (partial db.util/query (:schema/table schema))) + +(def delete (partial db.util/delete (:schema/table schema))) diff --git a/src/yetibot/core/handler.clj b/src/yetibot/core/handler.clj index 4ad100f1..035d01fb 100644 --- a/src/yetibot/core/handler.clj +++ b/src/yetibot/core/handler.clj @@ -55,9 +55,10 @@ (defn handle-parsed-expr "Top-level for already-parsed commands. Turns a parse tree into a string or collection result." - [chat-source user yetibot-user parse-tree] + [chat-source user resolved-users yetibot-user parse-tree] (binding [interp/*current-user* user interp/*yetibot-user* yetibot-user + interp/*resolved-users* resolved-users interp/*chat-source* chat-source] (transformer parse-tree))) @@ -88,10 +89,10 @@ Otherwise returns nil for non expressions. " - [body user yetibot-user & [{:keys [record-yetibot-response?] - :or {record-yetibot-response? true}}]] + [body user resolved-users yetibot-user & [{:keys [record-yetibot-response?] + :or {record-yetibot-response? true}}]] (trace "record-and-run-raw" body record-yetibot-response? - interp/*chat-source*) + interp/*chat-source*) (let [{:keys [adapter room uuid is-private] :as chat-source} interp/*chat-source* timestamp (System/currentTimeMillis) @@ -101,15 +102,15 @@ ;; - the result that Yetibot posts back to chat correlation-id (str timestamp "-" (hash [chat-source user body])) parsed-normal-command (when-let - [[_ body] (extract-command body)] + [[_ body] (extract-command body)] (parser body)) parsed-cmds (or ;; if it starts with a command prefix (e.g. !) it's a command - (and parsed-normal-command [parsed-normal-command]) + (and parsed-normal-command [parsed-normal-command]) ;; otherwise, check to see if there are embedded commands - (when (embedded-enabled?) (embedded-cmds body))) + (when (embedded-enabled?) (embedded-cmds body))) cmd? (boolean (seq parsed-cmds))] ;; record the body of users' messages if the user is not Yetibot @@ -134,51 +135,53 @@ (when (and cmd? (not (:yetibot? user))) (let [[results timeout-result] (alts!! - [(go (map - (fn [parse-tree] - (try - (let [original-command-str (unparse parse-tree) - {:keys [value error]} (handle-parsed-expr - chat-source user - yetibot-user - parse-tree) - result (or value error) - error? (not (nil? error)) - [formatted-response _] (format-data-structure - result)] + [(go (map + (fn [parse-tree] + (try + (let [original-command-str (unparse parse-tree) + {:keys [value error]} (handle-parsed-expr + chat-source + user + resolved-users + yetibot-user + parse-tree) + result (or value error) + error? (not (nil? error)) + [formatted-response _] (format-data-structure + result)] ;; Yetibot should record its own response in ;; `history` table before/during posting it back to ;; the chat adapter. Then we can more easily ;; correlate request (e.g. commands from user) and ;; response (output from Yetibot) - (trace - record-yetibot-response? - "recording history" uuid room is-private - original-command-str formatted-response) - (when record-yetibot-response? - (h/add {:chat-source-adapter uuid - :chat-source-room room - :is-private is-private - :correlation-id correlation-id - :user-id (-> yetibot-user :id str) - :user-name (-> yetibot-user :username str) - :is-yetibot true - :is-command false - :is-error error? - :command original-command-str - :body formatted-response})) + (trace + record-yetibot-response? + "recording history" uuid room is-private + original-command-str formatted-response) + (when record-yetibot-response? + (h/add {:chat-source-adapter uuid + :chat-source-room room + :is-private is-private + :correlation-id correlation-id + :user-id (-> yetibot-user :id str) + :user-name (-> yetibot-user :username str) + :is-yetibot true + :is-command false + :is-error error? + :command original-command-str + :body formatted-response})) ;; don't report errors on embedded commands - {:embedded? (not parsed-normal-command) - :error? error? - :result result}) - (catch Throwable ex - (error "error handling expression:" body - (format-exception-log ex)) - {:embedded? (not parsed-normal-command) - :error? true - :result (format exception-format ex)}))) - parsed-cmds)) - (timeout expr-eval-timeout-ms)])] + {:embedded? (not parsed-normal-command) + :error? error? + :result result}) + (catch Throwable ex + (error "error handling expression:" body + (format-exception-log ex)) + {:embedded? (not parsed-normal-command) + :error? true + :result (format exception-format ex)}))) + parsed-cmds)) + (timeout expr-eval-timeout-ms)])] (or results [{:timeout? true :result (str "Evaluation of `" body "` timed out after " @@ -212,14 +215,14 @@ :react" [{:keys [adapter room uuid is-private] :as chat-source} user event-type yetibot-user - {:keys [body reaction] :as event-info}] + {:keys [resolved-users body reaction] :as event-info}] ;; Note: only :message and :react have a body (when (and body (= event-type :message)) (binding [interp/*chat-source* chat-source] (go ;; there may be multiple expr-results, as in the case of multiple ;; embedded commands in a single body - (let [expr-results (record-and-run-raw body user yetibot-user)] + (let [expr-results (record-and-run-raw body user resolved-users yetibot-user)] (run! (fn [{:keys [timeout? embedded? error? result]}] (if (or (not error?) (not embedded?)) diff --git a/src/yetibot/core/interpreter.clj b/src/yetibot/core/interpreter.clj index 26fa77eb..18349c48 100644 --- a/src/yetibot/core/interpreter.clj +++ b/src/yetibot/core/interpreter.clj @@ -11,6 +11,7 @@ (def ^:dynamic *current-user*) (def ^:dynamic *yetibot-user*) +(def ^:dynamic *resolved-users*) (def ^:dynamic *chat-source*) (defn handle-cmd @@ -38,6 +39,7 @@ {previous-value :value previous-data-collection :data-collection previous-data :data} acc + ;; this is the context map that's passed to all command handlers extra {:raw previous-value :data (or previous-data previous-value) :data-collection previous-data-collection @@ -46,6 +48,7 @@ :next-cmds next-cmds :user *current-user* :yetibot-user *yetibot-user* + :resolved-users *resolved-users* :chat-source *chat-source*} possible-opts (to-coll-if-contains-newlines previous-value)]