From d7de0e92d094ab0efefc2777cef97eb45c0a8663 Mon Sep 17 00:00:00 2001 From: Chauncey Date: Mon, 10 Jun 2013 08:09:45 -0600 Subject: [PATCH] First commit of jarvis-core --- .gitignore | 18 ++ .travis.yml | 10 + LICENSE | 21 ++ README.md | 255 ++++++++++++++++++ project.clj | 22 ++ src/main/clojure/jarvis/bot.clj | 59 ++++ src/main/clojure/jarvis/command.clj | 62 +++++ src/main/clojure/jarvis/flowdock/debug.clj | 10 + src/main/clojure/jarvis/plugins.clj | 114 ++++++++ src/main/clojure/jarvis/plugins/flows.clj | 13 + src/main/clojure/jarvis/plugins/help.clj | 21 ++ src/main/clojure/jarvis/plugins/join.clj | 29 ++ src/main/clojure/jarvis/test_util.clj | 78 ++++++ src/main/clojure/jarvis/util.clj | 34 +++ src/main/java/com/rallydev/jarvis/Bot.java | 44 +++ src/main/java/com/rallydev/jarvis/Groovy.java | 33 +++ .../java/com/rallydev/jarvis/Message.java | 18 ++ src/main/java/com/rallydev/jarvis/Plugin.java | 7 + src/main/resources/logback.xml | 35 +++ .../jarvis/plugins/groovy/Hello.groovy | 6 + src/test/clojure/jarvis/bot_test.clj | 4 + src/test/clojure/jarvis/command_test.clj | 60 +++++ src/test/clojure/jarvis/plugins/help_test.clj | 31 +++ src/test/clojure/jarvis/plugins_test.clj | 14 + src/test/clojure/jarvis/util_test.clj | 11 + 25 files changed, 1009 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 project.clj create mode 100644 src/main/clojure/jarvis/bot.clj create mode 100644 src/main/clojure/jarvis/command.clj create mode 100644 src/main/clojure/jarvis/flowdock/debug.clj create mode 100644 src/main/clojure/jarvis/plugins.clj create mode 100644 src/main/clojure/jarvis/plugins/flows.clj create mode 100644 src/main/clojure/jarvis/plugins/help.clj create mode 100644 src/main/clojure/jarvis/plugins/join.clj create mode 100644 src/main/clojure/jarvis/test_util.clj create mode 100644 src/main/clojure/jarvis/util.clj create mode 100644 src/main/java/com/rallydev/jarvis/Bot.java create mode 100644 src/main/java/com/rallydev/jarvis/Groovy.java create mode 100644 src/main/java/com/rallydev/jarvis/Message.java create mode 100644 src/main/java/com/rallydev/jarvis/Plugin.java create mode 100644 src/main/resources/logback.xml create mode 100644 src/plugins/jarvis/plugins/groovy/Hello.groovy create mode 100644 src/test/clojure/jarvis/bot_test.clj create mode 100644 src/test/clojure/jarvis/command_test.clj create mode 100644 src/test/clojure/jarvis/plugins/help_test.clj create mode 100644 src/test/clojure/jarvis/plugins_test.clj create mode 100644 src/test/clojure/jarvis/util_test.clj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..123ca1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/target +/lib +/classes +/checkouts +/logs +pom.xml +*.jar +*.class +.lein-deps-sum +.lein-failures +.lein-plugins +*.ipr +*.iws +*.iml +.lein-repl-history +*.log +*.gz +.DS_Store diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..85d7e16 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: clojure +lein: lein2 +script: lein2 test +jdk: + - openjdk6 + - openjdk7 + - oraclejdk7 + +notifications: + email: false \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..955f74d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) Rally Software Development Corp. 2013 (see https://github.com/RallySoftware-cookbooks/jarvis) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b484434 --- /dev/null +++ b/README.md @@ -0,0 +1,255 @@ +# Jarvis + +A FlowDock robot similar to [Hubot](http://hubot.github.com/). + +## Why not just use Hubot? + +I think Jarvis can take advantage of the full power of FlowDock. Context. Think of all those build messages that get +sent to FlowDock. Wouldn't it be nice if you could reply to those messages and have Jarvis do work for you. Reply to +a failed build message in FlowDock with: + +``` +~claim portfolio_item/bulk_edit_spec.rb failed again +``` + +Notice that we didn't have to tell Jarvis which build to claim. Jarvis can infer that information from the context +of message command. + +## How do I use Jarvis? + +The Jarvis jar file includes a bot namespace which exposes an init function. This will spin up a new thread pool with a specified number of threads and connect to the flows that he has been invited to. + +For example - + +```clojure +(ns my-jarvis.core + (:require [jarvis.bot :as jarvis])) + +(defn -main [] + (jarvis/init)) +``` + +## How is Jarvis different from Hubot. + +* Jarvis works on the JVM. +* Plugins can currently be created with Groovy, Java and Clojure. It should be easy to add support for other JVM languages too. +* Jarvis only works with FlowDock +* Jarvis is developed in a more functional style. (full FlowDock message) -> (response) +* Jarvis is designed to take full advantage of FlowDock context. +* Jarvis can be used in private flows. +* Jarvis can be invited to flows, and be asked to leave. + +## How to discover available commands +Type ~help in a flow that Jarvis has been invited to. You will receive a reply along these lines, with examples of the required syntax for parameterized commands: +``` +assign-team: assign-team - Assigns the user to the GitHub team. +build: Trigger build pipeline (which may cause a deployment) for specified service. +claim: claim - Claims a broken build. - This is broken until we get Jenkins authentication for Jarvis. +create-repo: create-repo - Creates a GitHub repo. +health-report: Prints the health report for the specified job. +hello: Simple Hello from Groovy +help: Prints the description of all plugins. +join: Join the flow that is specified. +last-successful-build: Prints the last successful build for the specified job. +list-flows: Prints all flows that the jarvis is listening for. +list-github-teams: list-github-teams - Shows the list of all our Github teams. +status: Determines the status of all downstream jobs from the specified job. +~leave can be used to have Jarvis leave a flow. He will need to be invited back through the flowdock api to join again. +~ can be used also in private-messages with Jarvis. +~~ @ will private message the people listed the results of the executed command. +``` + +## Example Plugin + +```groovy +import com.rallydev.jarvis.Bot + +Bot.addCommand("hello", "Simple Hello", "Adam Esterline") { message -> + "Hello from Groovy" +} +``` + +## What information does a FlowDock message contain? + +FlowDock has a pretty good [API](https://www.flowdock.com/api/). But... with any documentation, it is not perfect. +I have taken the time to "document" some of the API in the [wiki](https://github.com/RallySoftware/jarvis/wiki). + +## Jarvis specific message information + +Jarvis adds information to the basic FlowDock information. This information allows plugin developers to access +FlowDock "context" information. Two pieces of information have been altered: ```user, parent```. + +### user + +In the original ```user``` key, the user value was the user id of the sender of the message. Jarvis takes this an extra +step and adds all the user information. + +```json +{ "app" : null, + "attachments" : [ ], + "content" : { "last_activity" : 1361748229643 }, + "event" : "activity.user", + "flow" : "rally-software:the-fellowship", + "id" : 904125, + "parent" : null, + "sent" : 1361748227604, + "tags" : [ ], + "user" : { "avatar" : "https://d2cxspbh1aoie1.cloudfront.net/avatars/3a5f36a2e4e283537ee9e037d9390dbd/", + "email" : "aesterline@rallydev.com", + "id" : 29990, + "name" : "Adam Esterline", + "nick" : "Adam" + }, + "uuid" : null +} +``` + +### parent + +If the current message is a comment to a message, Jarvis will add a ```parent``` key to the message map. This key +will contain the original message for the comment. + +```json +{ "app" : "chat", + "attachments" : [ ], + "content" : { "text" : "$example", + "title" : "Everything operating normally." + }, + "event" : "comment", + "flow" : "rally-software:the-fellowship", + "id" : 904196, + "parent" : { "app" : "influx", + "attachments" : [ ], + "content" : { "contributors" : null, + "coordinates" : null, + "created_at" : "Fri Feb 22 20:41:43 +0000 2013", + "entities" : { "hashtags" : [ ], + "urls" : [ ], + "user_mentions" : [ ] + }, + "favorited" : false, + "filter_level" : "medium", + "geo" : null, + "id" : 305054788356280320, + "id_str" : "305054788356280320", + "in_reply_to_screen_name" : null, + "in_reply_to_status_id" : null, + "in_reply_to_status_id_str" : null, + "in_reply_to_user_id" : null, + "in_reply_to_user_id_str" : null, + "place" : null, + "retweet_count" : 0, + "retweeted" : false, + "source" : "OctoStatus Production", + "text" : "Everything operating normally.", + "truncated" : false, + "user" : { "contributors_enabled" : false, + "created_at" : "Tue Aug 28 00:04:59 +0000 2012", + "default_profile" : true, + "default_profile_image" : false, + "description" : null, + "favourites_count" : 0, + "follow_request_sent" : null, + "followers_count" : 1305, + "following" : null, + "friends_count" : 1, + "geo_enabled" : false, + "id" : 785764172, + "id_str" : "785764172", + "is_translator" : false, + "lang" : "en", + "listed_count" : 43, + "location" : "", + "name" : "GitHub Status", + "notifications" : null, + "profile_background_color" : "C0DEED", + "profile_background_image_url" : "http://a0.twimg.com/images/themes/theme1/bg.png", + "profile_background_image_url_https" : "https://si0.twimg.com/images/themes/theme1/bg.png", + "profile_background_tile" : false, + "profile_image_url" : "http://a0.twimg.com/profile_images/2577880769/687474703a2f2f636c2e6c792f696d6167652f337330463237324b3254324c2f636f6e74656e74_normal.png", + "profile_image_url_https" : "https://si0.twimg.com/profile_images/2577880769/687474703a2f2f636c2e6c792f696d6167652f337330463237324b3254324c2f636f6e74656e74_normal.png", + "profile_link_color" : "0084B4", + "profile_sidebar_border_color" : "C0DEED", + "profile_sidebar_fill_color" : "DDEEF6", + "profile_text_color" : "333333", + "profile_use_background_image" : true, + "protected" : false, + "screen_name" : "githubstatus", + "statuses_count" : 131, + "time_zone" : "Pacific Time (US & Canada)", + "url" : "http://status.github.com/", + "utc_offset" : -28800, + "verified" : false + } + }, + "edited" : null, + "event" : "twitter", + "flow" : "rally-software:the-fellowship", + "id" : 848344, + "sent" : 1361565704184, + "tags" : [ ":thread" ], + "user" : 0 + }, + "sent" : 1361748434205, + "tags" : [ "influx:848344" ], + "user" : { "avatar" : "https://d2cxspbh1aoie1.cloudfront.net/avatars/3a5f36a2e4e283537ee9e037d9390dbd/", + "email" : "aesterline@rallydev.com", + "id" : 29990, + "name" : "Adam Esterline", + "nick" : "Adam" + }, + "uuid" : "Lq4SLeiil4NXb5ud" +} +``` + + +## How can I test my plugin? + +Take a look at the tests currently written for the +[existing](https://github.com/RallySoftware/jarvis/tree/master/src/test/clojure/jarvis/plugins) +[plugins](https://github.com/RallySoftware/jarvis/blob/master/src/test/clojure/jarvis/help_test.clj). + +## How do I try out my plugin? + +The Jarvis core is written in Clojure, so you will need to install +[Leiningen] (https://github.com/technomancy/leiningen#installation). Once you have install Leiningen, run the below +commands to start Jarvis and try out your plugin. + +``` +> export FLOWDOCK_TOKEN="" +> lein run +``` +Your FlowDock token is located on the account page. https://www.flowdock.com/account/tokens + +## TODO + +* Log some of the things + * ~~Plugin discovery and registration~~ - Currently everything is logged to STDOUT. + * ~~Incoming messages (at least in debug mode)~~ ```(listen-and-reply true)``` +* Prefetch users so that Jarvis warms up faster +* ~~Treat the stream of messages like a infinite sequence. I think this will make it easier to play with messages in the repl.~~ +* Add support for JavaScript/CoffeeScript plugins +* ~~Add a better way to start Jarvis. Maybe just a script at the root of the project.~~ +* Add support for different plugin directories that are outside of the project. + +## Why the name Jarvis? + +See [http://ironman.wikia.com/wiki/J.A.R.V.I.S.](http://ironman.wikia.com/wiki/J.A.R.V.I.S.) + +## Dependencies +```clojure +[org.clojure/clojure "1.5.0"] +[com.rallydev/clj-flowdock "1.1.0"] +[org.codehaus.groovy/groovy-all "2.1.0"] +[org.clojure/java.classpath "0.2.0"] +[org.clojure/tools.logging "0.2.6"] +[clj-http "0.7.2"] +[fs "1.3.2"] +[ch.qos.logback/logback-classic "1.0.9"] +[compojure "1.1.3"] +[netty-ring-adapter "0.2.4"] +``` + +# License +Copyright (c) Rally Software Development Corp. 2013 +Distributed under the MIT License. diff --git a/project.clj b/project.clj new file mode 100644 index 0000000..4f53af6 --- /dev/null +++ b/project.clj @@ -0,0 +1,22 @@ +(use '[clojure.java.shell :only [sh]]) +(require '(leiningen deploy)) + +(defproject com.rallydev/jarvis-core "1.0.0" + :description "A robot for FlowDock" + :url "http://github.com/RallySoftware/jarvis-core" + :license {:name "The MIT License (MIT)" + :url "http://opensource.org/licenses/MIT"} + :dependencies [[org.clojure/clojure "1.5.0"] + [com.rallydev/clj-flowdock "1.1.0"] + [org.codehaus.groovy/groovy-all "2.1.0"] + [org.clojure/java.classpath "0.2.0"] + [org.clojure/tools.logging "0.2.6"] + [clj-http "0.7.2"] + [fs "1.3.2"] + [ch.qos.logback/logback-classic "1.0.9"]] + :source-paths ["src/main/clojure" "src/plugins"] + :java-source-paths ["src/main/java"] + :test-paths ["src/test/clojure"] + :resource-paths ["src/main/resources"] + :plugins [[lein-clojars "0.9.1"]] + :jvm-opts ["-Xmx1G" "-DLOG_DIR=./logs"]) diff --git a/src/main/clojure/jarvis/bot.clj b/src/main/clojure/jarvis/bot.clj new file mode 100644 index 0000000..3e2d6b8 --- /dev/null +++ b/src/main/clojure/jarvis/bot.clj @@ -0,0 +1,59 @@ +(ns jarvis.bot + (:require [jarvis.command :as command] + [jarvis.plugins :as plugins] + [jarvis.util :as util] + [clj-flowdock.api.flow :as flow] + [clj-flowdock.streaming :as streaming] + [clojure.tools.logging :as log]) + (:import [java.util.concurrent Executors ExecutorService]) + (:gen-class )) + +(declare init-flow-thread) + +(def threadpool (atom (Executors/newFixedThreadPool (util/config-property "THREAD_POOL_SIZE" 100)))) + +(defmacro listen [[flow message-sym flow-con-sym] & body] + `(with-open [~flow-con-sym (streaming/open (flow/flow->flow-id ~flow))] + (loop [] + (when-let [~message-sym (.read ~flow-con-sym)] + ~@body) + (recur)))) + +(defn invoke-plugin [raw-message plugins] + (when-let [plugin (command/command->plugin raw-message plugins)] + (let [message (util/enhance-message raw-message)] + (try + (cond + (command/tell-command? message) (command/tell message plugin) + :else (command/reply message plugin)) + (catch Exception e + (log/error e (plugins/command-name plugin) " threw an exception")))))) + +(defn flow-stream [flow plugins] + (listen [flow msg flow-connection] + (cond + (command/leave-command? msg) (util/close-flow-connection flow-connection) + :else (invoke-plugin msg plugins)))) + +(defn user-stream [plugins] + (listen ["" msg flow-connection] + (cond + (command/join-command? msg) (init-flow-thread (get msg "content") plugins) + (command/private-message? msg) (command/private-message msg (command/command->plugin msg plugins))))) + +(defn init-flow-thread [flow plugins] + (.submit @threadpool #(flow-stream flow plugins))) + +(defn init-user-thread [plugins] + (.submit @threadpool #(user-stream plugins))) + +(defn init-threads [plugins] + (doseq [flow (flow/list)] + (init-flow-thread flow plugins)) + (init-user-thread plugins)) + +(defn init [] + (log/info "Starting Jarvis...") + (when-let [plugins (plugins/load-plugins)] + (log/info "Starting to read from flowdock streams") + (init-threads plugins))) \ No newline at end of file diff --git a/src/main/clojure/jarvis/command.clj b/src/main/clojure/jarvis/command.clj new file mode 100644 index 0000000..4d9be58 --- /dev/null +++ b/src/main/clojure/jarvis/command.clj @@ -0,0 +1,62 @@ +(ns jarvis.command + (:require [clj-flowdock.api.message :as m] + [clojure.string :as s] + [clojure.tools.logging :as log])) + +(declare get-user-id-from-message get-user-ids-from-tags user->id) + +(defn tell [message plugin] + (when-let [reply (plugin message)] + (let [user-ids (get-user-ids-from-tags message)] + (log/info "Plugin:" (m/content message) "replied:" (str "'" reply "'")) + (m/send-private-messages user-ids reply)))) + +(defn private-message [message plugin] + (when-let [reply (plugin message)] + (let [user-id (get-user-id-from-message message)] + (log/info "Plugin:" (m/content message) "replied to user:" user-id " with content:" (str "'" reply "'")) + (m/send-private-message user-id reply)))) + +(defn reply [message plugin] + (when-let [reply (plugin message)] + (log/info "Plugin:" (m/content message) "replied:" (str "'" reply "'")) + (m/reply message reply))) + +(defn tell-command? [message] + (let [content (m/content message)] + (.startsWith content "~~"))) + +(defn private-message? [message] + (contains? message "to")) + +(defn leave-command? [message] + (when-let [content (m/content message)] + (and + (not (map? content)) + (.equals content "~leave")))) + +(defn join-command? [msg] + (= "flow-add" (get msg "event"))) + +(defn parse-command [message-content] + (let [content-vec (s/split message-content #" ") + command (first content-vec)] + (cond + (.startsWith command "~~") (subs command 2) + (.startsWith command "~") (subs command 1)))) + +(defn command->plugin [message plugins] + (when-let [content (m/content message)] + (when-not (map? content) + (get plugins (parse-command content))))) + +(defn get-user-id-from-message [message] + (get-in message ["user" "id"])) + +(defn get-user-ids-from-tags [message] + (->> (message "tags") + (filter #(.startsWith % ":user:")) + (map #(user->id %)))) + +(defn- user->id [user] + (re-find #"\d+" user)) \ No newline at end of file diff --git a/src/main/clojure/jarvis/flowdock/debug.clj b/src/main/clojure/jarvis/flowdock/debug.clj new file mode 100644 index 0000000..61bd344 --- /dev/null +++ b/src/main/clojure/jarvis/flowdock/debug.clj @@ -0,0 +1,10 @@ +(ns jarvis.flowdock.debug + (:require [clojure.java.io :as io] + [cheshire.core :as json] + [clojure.tools.logging :as log])) + +(defn print-message [message] + (-> message + json/generate-string + log/debug) + message) \ No newline at end of file diff --git a/src/main/clojure/jarvis/plugins.clj b/src/main/clojure/jarvis/plugins.clj new file mode 100644 index 0000000..44b62b6 --- /dev/null +++ b/src/main/clojure/jarvis/plugins.clj @@ -0,0 +1,114 @@ +(ns jarvis.plugins + (:require [fs.core :as fs] + [clojure.java.classpath :as classpath] + [clojure.string :as s] + [clojure.tools.logging :as log] + [clojure.set :as set]) + (:import [com.rallydev.jarvis Groovy Plugin] + [groovy.lang Closure])) + +(def ^:const plugin-pattern #".*jarvis/plugins/.*") +(def not-nil (complement nil?)) + +(def plugins-atom (atom #{})) +(def meta-mapping {"command" :command + "description" :description + "author" :author}) + +(defn- apply-meta [f meta-data] + (-> (zipmap (.keySet meta-data) (.values meta-data)) + (set/rename-keys meta-mapping) + (->> + (with-meta f)))) + +(defn- save-plugin [f meta-data] + (swap! plugins-atom conj (apply-meta f meta-data))) + +(defn command-name [plugin] + (:command (meta plugin))) + +(defprotocol PluginAdapter + "Provides adapters for plugins from different JVM languages" + (add-plugin [plugin plugin-metadata])) + +(extend-type Plugin + PluginAdapter + (add-plugin [plugin plugin-metadata] + (save-plugin #(.invoke plugin %) plugin-metadata))) + +(extend-type Closure + PluginAdapter + (add-plugin [plugin plugin-metadata] + (save-plugin #(.call plugin (object-array [%])) plugin-metadata))) + +(defn plugin? [file] + (->> file + fs/absolute-path + (re-matches plugin-pattern) + not-nil)) + +(defn relative-to [directory-name file-name] + (if (.endsWith directory-name "/") + (s/replace-first file-name directory-name "") + (s/replace-first file-name (str directory-name "/") ""))) + +(defn absolute-path [file] + (if (fs/directory? file) + (fs/absolute-path file) + (fs/absolute-path (fs/file ".")))) + +(defn find-plugins [file] + (->> file + file-seq + (filter plugin?) + (map fs/absolute-path) + (map #(relative-to (absolute-path file) %)))) + +(defn classpath-jar-plugins [] + (->> (classpath/classpath-jarfiles) + (map #(classpath/filenames-in-jar %)) + flatten + (map #(fs/file %)) + (map find-plugins))) + +(defn classpath-file-plugins [] + (map find-plugins (classpath/classpath))) + +(defn classpath-plugins [] + (let [file-plugins (classpath-file-plugins) + jar-plugins (classpath-jar-plugins) + plugins (flatten (conj file-plugins jar-plugins))] + (log/info "Found plugins in -- [" plugins "]") + plugins)) + +(defn load-java-plugin [plugin] + (-> plugin + (s/replace ".class" "") + (s/replace "/" ".") + Class/forName)) + +(defn require-clojure-plugin [plugin] + (require (fs/path-ns plugin))) + +(defn load-plugin [plugin] + (log/info "Loading plugin: " plugin) + (cond + (.endsWith plugin ".class") (load-java-plugin plugin) + (.endsWith plugin ".groovy") (Groovy/loadGroovyPlugin (str "/" plugin)) + (.endsWith plugin ".clj") (require-clojure-plugin plugin))) + +(defn- load-clojure-plugins [] + (->> (all-ns) + (map (comp vals ns-publics)) + flatten + (filter (comp :plugin meta)) + (apply swap! plugins-atom conj))) + +(defn plugins->map [plugins] + (into {} (for [p plugins] [(-> p meta :command ) p]))) + +(defn load-plugins [] + (log/info "Loading Plugins...") + (dorun (map load-plugin (classpath-plugins))) + (load-clojure-plugins) + (plugins->map @plugins-atom)) diff --git a/src/main/clojure/jarvis/plugins/flows.clj b/src/main/clojure/jarvis/plugins/flows.clj new file mode 100644 index 0000000..44e4158 --- /dev/null +++ b/src/main/clojure/jarvis/plugins/flows.clj @@ -0,0 +1,13 @@ +(ns jarvis.plugins.flows + (:require [jarvis.plugins :as plugins] + [clj-flowdock.api.message :as message] + [clj-flowdock.api.flow :as flow] + [clojure.string :as s])) + +(defn ^{:description "list-flows - Prints all flows that I am listening to." + :command "list-flows" + :author "Jonathan Chauncey" + :plugin true} + list-flows [message] + (let [flows (flow/list)] + (s/join ", " (map #(% "name") flows)))) \ No newline at end of file diff --git a/src/main/clojure/jarvis/plugins/help.clj b/src/main/clojure/jarvis/plugins/help.clj new file mode 100644 index 0000000..6d6e5ff --- /dev/null +++ b/src/main/clojure/jarvis/plugins/help.clj @@ -0,0 +1,21 @@ +(ns jarvis.plugins.help + (:require [jarvis.plugins :as plugins] + [clojure.string :as s])) + +(defn ^{:description "Prints the description of all plugins." + :command "help" + :author "Adam Esterline" + :plugin true} + help + ([message] (help message @plugins/plugins-atom)) + ([message plugins] + (let [plugin-meta (map meta plugins) + sorted-plugin-meta (sort-by :command plugin-meta) + help-text (map #(str "\t" (:command %) ":\t" (:description %)) sorted-plugin-meta)] + (str + "\tAll known plugins\n" + (s/join "\n" help-text) + "\n\t~leave can be used to have me leave a flow. I will need to be invited back through the flowdock api to join again." + "\n\t~ can be used also in private-messages with me." + "\n\t~~ @ will private message the people listed the results of the executed command." + "\n\tFor more details see https://github.com/RallySoftware/jarvis")))) diff --git a/src/main/clojure/jarvis/plugins/join.clj b/src/main/clojure/jarvis/plugins/join.clj new file mode 100644 index 0000000..8c4a36c --- /dev/null +++ b/src/main/clojure/jarvis/plugins/join.clj @@ -0,0 +1,29 @@ +(ns jarvis.plugins.join + (:require [jarvis.bot :as bot] + [jarvis.plugins :as plugins] + [clj-flowdock.api.flow :as f] + [clj-flowdock.api.user :as user] + [clj-flowdock.api.message :as m] + [clojure.string :as s])) + +(declare join-flows join-flow) + +(defn ^{:description "join - Join the flow that is specified." + :command "join" + :author "Jonathan Chauncey, Matt Farrar" + :plugin true} + join [message] + (join-flows message @plugins/plugins-atom)) + +(defn- join-flows [message plugins] + (let [flows (rest (s/split (m/content message) #"\s")) + reply (map #(join-flow % plugins) flows)] + (str "" (s/join "\n" reply)))) + +(defn- join-flow [flow-name plugins] + (if-let [flow (f/find "name" flow-name)] + (do + (f/add-myself flow) + (bot/init-flow-thread flow plugins) + (str "Joined flow - " flow-name)) + (str "Could not join flow - " flow-name ". Try inviting me through the UI with my email address."))) \ No newline at end of file diff --git a/src/main/clojure/jarvis/test_util.clj b/src/main/clojure/jarvis/test_util.clj new file mode 100644 index 0000000..2c9b1bf --- /dev/null +++ b/src/main/clojure/jarvis/test_util.clj @@ -0,0 +1,78 @@ +(ns jarvis.test-util) + +(defn message [content] + {"app" "chat", + "parent" nil, + "user" {"nick" "Chauncey", + "name" "Jonathan Chauncey", + "avatar" "https://d2cxspbh1aoie1.cloudfront.net/avatars/7d17d1f0d22a0f9fff783f0d92a7ab13/", + "id" :29983, + "email" "jchauncey@rallydev.com"}, + "flow" "rally-software:jarvish", + "attachments" [], + "sent" :1368111056178, + "tags" [], + "content" content, + "event" "message", + "id" 12655, + "uuid" "83kTk4vQbNk3uI7v"}) + +(defn private-message [content] + {"app" "chat", + "parent" nil, + "user" {"nick" "Chauncey", + "name" "Jonathan Chauncey", + "avatar" "https://d2cxspbh1aoie1.cloudfront.net/avatars/7d17d1f0d22a0f9fff783f0d92a7ab13/", + "id" :29983, + "email" "jchauncey@rallydev.com"}, + "flow" "rally-software:jarvish", + "attachments" [], + "sent" :1368111056178, + "tags" [], + "content" content, + "event" "message", + "id" 12655, + "to" 35899 + "uuid" "83kTk4vQbNk3uI7v"}) + +(defn tagged-message [] + {"app" "chat", + "parent" nil, + "user" {"nick" "Chauncey", + "name" "Jonathan Chauncey", + "avatar" "https://d2cxspbh1aoie1.cloudfront.net/avatars/7d17d1f0d22a0f9fff783f0d92a7ab13/", + "id" :29983, + "email" "jchauncey@rallydev.com"}, + "flow" "rally-software:jarvish", + "attachments" [], + "sent" :1368111056178, + "tags" [":user:29988" ":user:1234" ":unread:29988" ":user:35899" ":unread:35899"], + "content" "~~help @Fatt", + "event" "message", + "id" 12655, + "uuid" "83kTk4vQbNk3uI7v"}) + +(defn tagged-message-with-content [content] + {"app" "chat", + "parent" nil, + "user" {"nick" "Chauncey", + "name" "Jonathan Chauncey", + "avatar" "https://d2cxspbh1aoie1.cloudfront.net/avatars/7d17d1f0d22a0f9fff783f0d92a7ab13/", + "id" :29983, + "email" "jchauncey@rallydev.com"}, + "flow" "rally-software:jarvish", + "attachments" [], + "sent" :1368111056178, + "tags" [":user:29988" ":user:1234" ":unread:29988" ":user:35899" ":unread:35899"], + "content" content, + "event" "message", + "id" 12655, + "uuid" "83kTk4vQbNk3uI7v"}) + +(defn flow-add-message [] + {"user" 35899, + "event" "flow-add", + "content" {"id" "rally-software:devellopers", + "name" "developers", + "organization" "Rally Software", + "open" true}}) \ No newline at end of file diff --git a/src/main/clojure/jarvis/util.clj b/src/main/clojure/jarvis/util.clj new file mode 100644 index 0000000..0f398c2 --- /dev/null +++ b/src/main/clojure/jarvis/util.clj @@ -0,0 +1,34 @@ +(ns jarvis.util + (:require [jarvis.plugins :as plugins] + [jarvis.flowdock.debug :as debug] + [clj-flowdock.api.message :as m] + [clj-flowdock.api.flow :as f] + [clj-flowdock.api.user :as user] + [clojure.string :as s] + [clojure.tools.logging :as log])) + +(defn- env-variable [name] + (-> (System/getenv) + (get name))) + +(defn config-property + ([name] + (config-property name nil)) + ([name default-value] + (if-let [env-value (env-variable name)] + env-value + (if-let [system-value (System/getProperty name)] + system-value + default-value)))) + +(defn enhance-message [message] + (-> message + (assoc "parent" (m/parent-message message)) + (assoc "user" (user/get (m/user message))) + debug/print-message)) + +(defn close-flow-connection [flow-connection] + (log/info (str "Closing flow connection - " (.flow-id flow-connection))) + (m/chat (.flow-id flow-connection) "Goodbye cruel flow.") + (f/block-user (.flow-id flow-connection) (get (user/me) "id")) + (.close flow-connection)) \ No newline at end of file diff --git a/src/main/java/com/rallydev/jarvis/Bot.java b/src/main/java/com/rallydev/jarvis/Bot.java new file mode 100644 index 0000000..4a9d29f --- /dev/null +++ b/src/main/java/com/rallydev/jarvis/Bot.java @@ -0,0 +1,44 @@ +package com.rallydev.jarvis; + +import clojure.lang.RT; +import clojure.lang.Symbol; +import clojure.lang.Var; +import groovy.lang.Closure; + +import java.util.HashMap; +import java.util.Map; + +public abstract class Bot { + public static void addPlugin(Object plugin, Map metadata) { + ADD_PLUGIN.invoke(plugin, metadata); + } + + public static void addCommand(String commandName, String description, String author, final Closure callback) { + HashMap map = new HashMap(); + map.put("command", commandName); + map.put("description", description); + map.put("author", author); + + addPlugin(new GroovyCommand(callback, commandName), map); + } + + private static final Var REQUIRE = RT.var("clojure.core", "require"); + static { + REQUIRE.invoke(Symbol.intern("jarvis.plugins")); + } + private static final Var ADD_PLUGIN = RT.var("jarvis.plugins", "add-plugin"); + + private static class GroovyCommand implements Plugin { + private final Closure callback; + private final String command; + + private GroovyCommand(Closure callback, String command) { + this.callback = callback; + this.command = command; + } + + public Object invoke(Map message) { + return callback.call(message); + } + } +} diff --git a/src/main/java/com/rallydev/jarvis/Groovy.java b/src/main/java/com/rallydev/jarvis/Groovy.java new file mode 100644 index 0000000..4a5f003 --- /dev/null +++ b/src/main/java/com/rallydev/jarvis/Groovy.java @@ -0,0 +1,33 @@ +package com.rallydev.jarvis; + +import groovy.lang.Binding; +import groovy.util.GroovyScriptEngine; +import groovy.util.ResourceConnector; +import groovy.util.ResourceException; + +import java.net.URL; +import java.net.URLConnection; + +public abstract class Groovy { + + private static class ClasspathResourceConnector implements ResourceConnector { + + public URLConnection getResourceConnection(String name) throws ResourceException { + try { + if(name.startsWith("/")) { + return getClass().getResource(name).openConnection(); + } else { + return new URL(name).openConnection(); + } + } catch(Exception e) { + throw new ResourceException(e); + } + } + } + + private static final GroovyScriptEngine SCRIPT_ENGINE = new GroovyScriptEngine(new ClasspathResourceConnector()); + + public static void loadGroovyPlugin(String script) throws Exception { + SCRIPT_ENGINE.run(script, new Binding()); + } +} diff --git a/src/main/java/com/rallydev/jarvis/Message.java b/src/main/java/com/rallydev/jarvis/Message.java new file mode 100644 index 0000000..54b29e7 --- /dev/null +++ b/src/main/java/com/rallydev/jarvis/Message.java @@ -0,0 +1,18 @@ +package com.rallydev.jarvis; + +import clojure.lang.RT; +import clojure.lang.Symbol; +import clojure.lang.Var; + +public abstract class Message { + public static String parseCommand(Object message) { + return (String) PARSE_COMMAND.invoke(message); + } + + private static final Var REQUIRE = RT.var("clojure.core", "require"); + static { + REQUIRE.invoke(Symbol.intern("jarvis.command")); + } + private static final Var PARSE_COMMAND = RT.var("jarvis.command", "parse-command"); + +} diff --git a/src/main/java/com/rallydev/jarvis/Plugin.java b/src/main/java/com/rallydev/jarvis/Plugin.java new file mode 100644 index 0000000..175c38c --- /dev/null +++ b/src/main/java/com/rallydev/jarvis/Plugin.java @@ -0,0 +1,7 @@ +package com.rallydev.jarvis; + +import java.util.Map; + +public interface Plugin { + public Object invoke(Map message); +} \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..fedb412 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + System.out + + %date{MM/dd HH:mm:ss} %-5level[%thread] %logger{1} - %msg%n + + + + + ${LOG_DIR}/message.log + + ${LOG_DIR}/message.log.%d{yyyy-MM-dd}.gz + 2 + + + %m%n + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/plugins/jarvis/plugins/groovy/Hello.groovy b/src/plugins/jarvis/plugins/groovy/Hello.groovy new file mode 100644 index 0000000..ace8ade --- /dev/null +++ b/src/plugins/jarvis/plugins/groovy/Hello.groovy @@ -0,0 +1,6 @@ +import com.rallydev.jarvis.Bot + +Bot.addCommand("hello", "Simple Hello from Groovy", "Adam Esterline") { message -> + "Hello from Groovy" +} + diff --git a/src/test/clojure/jarvis/bot_test.clj b/src/test/clojure/jarvis/bot_test.clj new file mode 100644 index 0000000..2201c5b --- /dev/null +++ b/src/test/clojure/jarvis/bot_test.clj @@ -0,0 +1,4 @@ +(ns jarvis.bot-test + (:use clojure.test) + (:require [jarvis.bot :as bot] + [jarvis.test-util :as test-util])) diff --git a/src/test/clojure/jarvis/command_test.clj b/src/test/clojure/jarvis/command_test.clj new file mode 100644 index 0000000..0d0ff25 --- /dev/null +++ b/src/test/clojure/jarvis/command_test.clj @@ -0,0 +1,60 @@ +(ns jarvis.command-test + (:use clojure.test) + (:require [jarvis.command :as command] + [jarvis.test-util :as test-util])) + +(deftest tell-command? + (is (= true + (command/tell-command? (test-util/message "~~help")))) + (is (= false + (command/tell-command? (test-util/message "~help")))) + (is (= false + (command/tell-command? (test-util/message "help"))))) + +(deftest parse-command + (is (= "help" + (command/parse-command "~help"))) + (is (= "help" + (command/parse-command "~~help"))) + (is (= nil + (command/parse-command "help")))) + +(deftest command->plugin + (is (= "foo" + (command/command->plugin (test-util/message "~help") {"help" "foo"}))) + (is (= "foo" + (command/command->plugin (test-util/message "~~help") {"help" "foo"}))) + (is (= nil + (command/command->plugin (test-util/message "help") {"help" "foo"})))) + +(deftest get-user-ids-from-tags + (is (= (list "29988" "1234" "35899" + (command/get-user-ids-from-tags (test-util/tagged-message)))))) + +(deftest get-user-id-from-message + (is (= :29983 + (command/get-user-id-from-message (test-util/private-message "~help"))))) + +(deftest leave-command? + (is (= true + (command/leave-command? (test-util/message "~leave")))) + (is (= false + (command/leave-command? (test-util/message "~~leave")))) + (is (= false + (command/leave-command? (test-util/message "~~leave foo-flow"))))) + +(deftest join-command? + (is (= true + (command/join-command? (test-util/flow-add-message)))) + (is (= false + (command/leave-command? (test-util/message "test")))) + (is (= false + (command/leave-command? (test-util/private-message "foo"))))) + +(deftest private-message? + (is (= true + (command/private-message? (test-util/private-message "private message")))) + (is (= false + (command/leave-command? (test-util/message "test")))) + (is (= false + (command/leave-command? (test-util/flow-add-message))))) \ No newline at end of file diff --git a/src/test/clojure/jarvis/plugins/help_test.clj b/src/test/clojure/jarvis/plugins/help_test.clj new file mode 100644 index 0000000..d8b78c4 --- /dev/null +++ b/src/test/clojure/jarvis/plugins/help_test.clj @@ -0,0 +1,31 @@ +(ns jarvis.plugins.help-test + (:use clojure.test) + (:require [jarvis.plugins.help :as help] + [jarvis.test-util :as test-util])) + +(defn + ^{:description "foo" :command "bar"} + test-plugin [message] + "baz") + +(defn + ^{:description "yo" :command "dawg"} + yo-plugin [message] + "happy") + +(deftest help + (is (= (str "\tAll known plugins\n" + "\tbar:\tfoo" + "\n\t~leave can be used to have me leave a flow. I will need to be invited back through the flowdock api to join again." + "\n\t~ can be used also in private-messages with me." + "\n\t~~ @ will private message the people listed the results of the executed command." + "\n\tFor more details see https://github.com/RallySoftware/jarvis") + (help/help (test-util/message "~help") [#'test-plugin]))) + (is (= (str "\tAll known plugins\n" + "\tbar:\tfoo\n" + "\tdawg:\tyo" + "\n\t~leave can be used to have me leave a flow. I will need to be invited back through the flowdock api to join again." + "\n\t~ can be used also in private-messages with me." + "\n\t~~ @ will private message the people listed the results of the executed command." + "\n\tFor more details see https://github.com/RallySoftware/jarvis") + (help/help (test-util/message "~help") [#'test-plugin #'yo-plugin])))) \ No newline at end of file diff --git a/src/test/clojure/jarvis/plugins_test.clj b/src/test/clojure/jarvis/plugins_test.clj new file mode 100644 index 0000000..25fb960 --- /dev/null +++ b/src/test/clojure/jarvis/plugins_test.clj @@ -0,0 +1,14 @@ +(ns jarvis.plugins-test + (:use clojure.test) + (:require [jarvis.plugins :as plugins] + [clojure.java.io :as io])) + +(deftest plugin? + (is (= true (plugins/plugin? (io/file "/jarvis/plugins/jenkins.clj")))) + (is (= false (plugins/plugin? (io/file "/jarvis/plugins.clj")))) + (is (= true (plugins/plugin? (io/file "/jarvis/config/jarvis/plugins/jenkins.clj"))))) + +(deftest relative-to + (is (= "jarvis/plugins/jenkins.clj" (plugins/relative-to "/Users/pairing" "/Users/pairing/jarvis/plugins/jenkins.clj"))) + (is (= "jarvis/plugins/jenkins.clj" (plugins/relative-to "/Users/pairing/" "/Users/pairing/jarvis/plugins/jenkins.clj"))) + (is (= "jarvis/plugins/jenkins.clj" (plugins/relative-to "/" "/jarvis/plugins/jenkins.clj")))) \ No newline at end of file diff --git a/src/test/clojure/jarvis/util_test.clj b/src/test/clojure/jarvis/util_test.clj new file mode 100644 index 0000000..ed87907 --- /dev/null +++ b/src/test/clojure/jarvis/util_test.clj @@ -0,0 +1,11 @@ +(ns jarvis.util-test + (:use [clojure.test]) + (:require [jarvis.test-util :as test-util] + [jarvis.util :as util])) + +(deftest config-property + (is (= nil (util/config-property "some-property"))) + (is (= (System/getenv "PWD") (util/config-property "PWD"))) + (System/setProperty "some-foo-property" "some-foo-value") + (is (= "some-foo-value" (util/config-property "some-foo-property"))) + (is (= "some-default-value" (util/config-property "some-property" "some-default-value")))) \ No newline at end of file