git.fiddlerwoaroof.com
slack-client.lisp
be6f8571
 (in-package :slacker)
 
 (defmethod attach-module ((event-pump event-pump) module &rest args &key)
   (setf (gethash (make-keyword module)
5c15279a
                  (modules event-pump))
         (apply #'make-instance
                module
                args)))
be6f8571
 
60e29634
 (defgeneric get-module (module event-pump)
   (:documentation "Get one of the activated modules")
   (:method (module (event-pump event-pump))
     (gethash (make-keyword module)
5c15279a
              (modules event-pump))))
60e29634
 
be6f8571
 (defvar *api-token*) 
 
 (defun make-client (event-pump)
   (flet ((get-ws-url (slack-response)
5c15279a
            (gethash "url" slack-response)))
be6f8571
     (fw.lu:let-each (:be slack-data)
       (format nil "https://slack.com/api/rtm.start?token=~a" *api-token*)
       (drakma:http-request slack-data :want-stream t)
       (yason:parse slack-data)
 
       (let* ((url (get-ws-url slack-data))
5c15279a
              (client (wsd:make-client url)))
         (setf (ws-client event-pump)
               client)
         (wsd:on :message client
                 (lambda (message)
                   #+null
                   (format t "~&Got a message ~a~%" message)
                   (chanl:send (result-queue event-pump)
                               message)))
         client))))
be6f8571
 
 (defgeneric send-message (client type &key)
   (:documentation "Send a slack message")
   (:method :around ((client event-pump) _type &key)
     (declare (ignorable client _type))
     (let ((result (call-next-method)))
5c15279a
       (values result
               (wsd:send (ws-client client)
                         (with-output-to-string (s)
                           (yason:encode result s)))))))
be6f8571
 
60e29634
 (defmethod send-message ((client event-pump) (type (eql :ping)) &key data)
   (let* ((id (incf (latest-id client)))
          (message `(("id" . ,id)
                     ("type" . "ping"))))
 
     (when data
       (aconsf message "data" data))
 
     (format t "~&pinging with id: ~a at time: ~a~%" id (local-time:now))
     (incf (waiting-pings client))
     (alist-hash-table message
                       :test 'equal)))
 
 (defmethod send-message ((client event-pump) (type (eql :message)) &key channel data thread)
   (let* ((id (incf (latest-id client)))
5c15279a
          (message `(("id" . ,id)
                     ("type" . "message")
                     ("channel" . ,channel)
                     ("text" . ,data)
                     ,@(unsplice
                        (when thread
                          `("thread_ts" . ,thread))))))
60e29634
     (alist-hash-table message
5c15279a
                       :test 'equal)))
60e29634
 
be6f8571
 
 (defgeneric start-module (client module)
   (:documentation "start a module"))
 
60e29634
 (define-condition connection-lost (error)
1543ce00
   ())
 
60e29634
 (defclass slack-client ()
   ((%commands :accessor commands :initform (make-hash-table))
    (%modules :accessor modules :initform '())))
 
 (defgeneric start (event-pump client &key queue-pair))
 
 (defgeneric bind (what where))
 (defmethod bind ((queue-pair queue-pair) (event-pump event-pump))
   (setf (queue-pair event-pump) queue-pair))
 
 (defun handle-work-queue (event-pump)
   (multiple-value-bind (message message-p)
       (chanl:recv (work-queue event-pump)
5c15279a
                   :blockp nil)
60e29634
     (when message-p
       (format t "Got a message")
       (funcall message
5c15279a
                event-pump))))
60e29634
 
 (defun send-pings (event-pump client)
811dd7ec
   "Ping slack for connectivity, error if we have too many waiting pings."
60e29634
   ;; Eventually drop client
   (declare (ignorable client))
   (if (> 100 (waiting-pings event-pump))
       (send-message event-pump :ping)
       (error 'connection-lost)))
 
 (defun network-loop (event-pump client-factory modules)
   (declare (optimize (debug 3)))
   (loop for (module . args) in modules
5c15279a
         do (start-module event-pump
                          (apply #'attach-module
                                 event-pump module args)))
60e29634
   (let ((client (funcall client-factory))
5c15279a
         (connected nil))
60e29634
     (as:with-event-loop () 
       (websocket-driver:start-connection client)
       (setf connected t)
       (as:with-interval (15)
5c15279a
         (when connected
           (restart-case (send-pings event-pump client)
             (restart-server ()
               (websocket-driver:close-connection client)
               (setf connected nil)
               (clear-waiting-pings event-pump)
               (as:with-delay (10)
811dd7ec
                 (cl+ssl:reset-library)
                 (websocket-driver:start-connection
                  (setf client (funcall client-factory)))
5c15279a
                 (setf connected t))))))
60e29634
       (as:with-interval (0.01)
5c15279a
         (when connected
           (handle-work-queue event-pump))
         :event-cb (lambda (ev)
                     (format t "~&EVENT: ~a~%" ev))))))
60e29634
 
 (defun start-client (&key (queue-pair (make-instance 'queue-pair)) modules)
148e6242
   (let* ((event-pump (make-instance 'event-pump :queue-pair queue-pair))
5c15279a
          (client-factory (op (make-client event-pump))))
148e6242
     (values event-pump
5c15279a
             (bt:make-thread (lambda ()
811dd7ec
                               (network-loop event-pump
                                             client-factory
                                             modules))
5c15279a
                             :name "Event Server"
                             :initial-bindings `((*api-token* . ,*api-token*))))))
be6f8571
 
 (defmethod get-event-nonblocking ((event-pump event-pump) &key (object-as :hash-table))
   (multiple-value-bind (message message-p) (chanl:recv (result-queue event-pump) :blockp nil)
     (values (when message-p
5c15279a
               (yason:parse message :object-as object-as))
             message-p)))
be6f8571
 
60e29634
 (defmethod get-event ((queue-pair queue-pair) &key (object-as :hash-table))
   (multiple-value-bind (message message-p) (chanl:recv (result-queue queue-pair))
be6f8571
     (values (when message-p
5c15279a
               (yason:parse message :object-as object-as))
             message-p)))
be6f8571
 
9cf1fdb1
 (defparameter *ignored-messages* '(:pong))
 (defgeneric handle-message (type event-pump ts channel message)
   (:method :before (type event-pump ts channel message)
     (declare (ignore event-pump ts channel))
     (unless (member type *ignored-messages*)
       (fresh-line)
       (yason:encode message)
       (terpri)))
   (:method (type (event-pump event-pump) ts channel message)
     (declare (ignore type event-pump ts channel message))
     nil)
   (:method ((type (eql :pong)) (event-pump event-pump) ts channel message)
     (declare (ignore ts type channel))
60e29634
     (format t "~&Was waiting on ~a pings," (waiting-pings event-pump))
9cf1fdb1
     (decf (waiting-pings event-pump))
60e29634
     (format t "after pong received for ~a, now waiting on ~a~%"
5c15279a
             (gethash "reply_to" message)
             (waiting-pings event-pump))))
60e29634
 
 (defmethod handle-message ((type (eql :message)) (event-pump event-pump) ts channel message)
   (format t "~&Received message ~s~%" message)
   (when-let* ((msg (gethash "text" message))
5c15279a
               (parsed-message (tokens msg))) 
60e29634
     (when (eql #\; (elt msg 0))
       (handle-command event-pump message channel
811dd7ec
                       (plump:decode-entities
                        (car parsed-message))
5c15279a
                       (cdr parsed-message))))) 
60e29634
 
be6f8571
 (defun event-loop (event-pump)
   (loop with message with message-p
5c15279a
         do (multiple-value-setq (message message-p) (get-event (queue-pair event-pump)))
         when message-p do
           (let ((type (gethash "type" message))
                 (reply (gethash "reply_to" message))
                 (ts (gethash "ts" message))
                 (channel (gethash "channel" message)))
             (cond (type
                    (handle-message (make-keyword (string-upcase type))
                                    event-pump ts channel message))
                   (reply )))
         do (sleep 0.01)))
be6f8571
 
148e6242
 (defun coordinate-threads (&optional queue-pair)
811dd7ec
   (let* ((event-pump (start-client :queue-pair queue-pair
                                    :modules '((hhgbot-augmented-assistant::js-executor)))))
5c15279a
     (bt:make-thread (lambda () (event-loop event-pump))
                     :name "Event Loop") 
be6f8571
     event-pump))
 
 (defparameter *command-table* (make-hash-table :test 'equal))
 
 (defun quote-output (str)
   (with-output-to-string (s)
     (format s "```~%~a```~%" str)))
 
 (defmacro in-wq ((client-sym) &body body)
   `(let ((promise (blackbird-base:make-promise)))
      (values promise
5c15279a
              (chanl:send (work-queue ,client-sym)
                          (lambda (,client-sym)
                            (declare (ignorable ,client-sym))
                            (let ((result (progn ,@body)))
                              (blackbird-base:finish promise result)
                              result))))))
be6f8571
 
60e29634
 (defun queue-message (event-pump channel message &key quote thread)
be6f8571
   (let ((message (if quote (quote-output message)
5c15279a
                      message)))
be6f8571
     (in-wq (event-pump)
       (send-message event-pump :message
5c15279a
                     :channel channel
                     :data message
                     :thread thread))))
be6f8571
 
 (define-condition command-error () ())
 (define-condition unsupported-args (command-error) ())
 
60e29634
 (defgeneric add-command ())
be6f8571
 (defmacro define-command (name (event-pump ts channel &rest args) &body body)
   (let* ((command-sym (intern (string-upcase name)))
5c15279a
          (has-rest (position '&rest args))
          (rest-sym (gensym "rest"))
          (args (if has-rest
                    args
                    (append args `(&rest ,rest-sym)))))
be6f8571
     `(progn
        (defun ,command-sym (,event-pump ,ts ,channel ,@args)
5c15279a
          (declare (ignorable ,event-pump ,ts ,@(when (not has-rest) `(,rest-sym))))
          ,@body)
60e29634
        (setf (gethash ,name *command-table*) ',command-sym))))
be6f8571
 
60e29634
 (defun safe-apply (func event-pump message channel args)
5ea361e0
   (with-simple-restart (continue "Skip command")
60e29634
     (apply func event-pump message channel args)))
5ea361e0
 
60e29634
 (defun handle-command (event-pump message channel command args)
be6f8571
   (declare (ignorable args))
811dd7ec
   (let* ((command (subseq command 1))
5c15279a
          (handler (gethash command *command-table*)))
be6f8571
     (print (hash-table-alist *command-table*))
     (terpri)
     (print command)
     (if handler
5c15279a
         (safe-apply handler event-pump message channel args)
811dd7ec
         (queue-message event-pump channel
                        (concat "I don't understand the command `" command "`.")
                        :thread (ensure-thread message)))))
5ea361e0
 
 (defun slack-api-call (method &rest args)
   (bb:with-promise (resolve reject)
     (bt:make-thread
      (lambda ()
        (handler-case
5c15279a
            (let ((api-result (yason:parse
                               (babel:octets-to-string 
                                (drakma:http-request (concat "https://slack.com/api/" method "?token=" *api-token*)
                                                     :method :post
                                                     :content (quri:url-encode-params
                                                               (loop for (key value) on args by #'cddr
                                                                     collect (cons (string-downcase key) value)))
                                                     )))))
                                         ;todo error handling . . .
              (resolve api-result)) 
          (t (c)
            (format t "~&Received condition ~s~%" c)
            (reject c)))))))
5ea361e0
 
9cf1fdb1
 ;; (defgeneric api-call (name args)
 ;;   (:method ((name symbol) (args list))
 ;;     (slack-api-call name)))
5ea361e0
 
 (defmacro define-api-wrapper (name required-args &rest args)
   (flet ((name-case (string)
5c15279a
            (let ((parts (split-sequence #\- (string-downcase string))))
              (apply #'concatenate 'string
                     (car parts)
                     (mapcar #'string-capitalize (cdr parts))))))
5ea361e0
     (let* ((api-method-name (name-case name)))
       `(progn (defun ,name (,@required-args &rest r &key ,@args)
5c15279a
                 (apply #'slack-api-call ,api-method-name
                        ,@(loop for req-arg in required-args
                                append (list (make-keyword req-arg) req-arg))
                        r))
               (eval-when (:compile-toplevel :load-toplevel :execute)
                 (let ((*package* (find-package 'slacker.api)))
                   (import ',name)
                   (export ',name)))))))
5ea361e0
 
 
 (defmacro define-api-wrappers (&body body)
   `(progn ,@(loop for (name required-args . rest) in body
5c15279a
                   collect `(define-api-wrapper ,name ,required-args ,@rest))))
be6f8571
 
 (defun edit-message (ts channel text)
   (babel:octets-to-string
    (drakma:http-request "https://slack.com/api/chat.update"
5c15279a
                         :method :post
                         :content (concat "token=" *api-token*
                                          "&channel=" channel
                                          "&ts=" ts
                                          "&text=" text))))
be6f8571
 
60e29634
 (defmacro with-output-to-message ((stream event-pump channel &key quote thread) &body body)
be6f8571
   (once-only (event-pump channel quote)
     `(queue-message ,event-pump ,channel
5c15279a
                     (with-output-to-string (,stream)
                       ,@body)
                     :quote ,quote
                     :thread ,thread)))
60e29634
 
 (defmacro with-thread-info ((ts thread-ts in-thread is-reply) message &body body)
   (once-only (message)
     `(let* ((,ts (gethash "ts" ,message))
5c15279a
             (,thread-ts (gethash "thread_ts" ,message))
             (,in-thread (not (null ,thread-ts)))
             (,is-reply (and ,in-thread (string/= ,ts ,thread-ts))))
60e29634
        ,@body)))
 
 (defun ensure-thread (message)
   "Continue thread or else start a new one"
   (with-thread-info (ts thread-ts in-thread is-reply) message
     (declare (ignore is-reply))
     (if in-thread thread-ts ts)))
 
 (defun keep-in-thread (message)
   "Continue thread or continue in main thread"
   (with-thread-info (ts thread-ts in-thread is-reply) message
     (declare (ignore is-reply))
     (if in-thread thread-ts nil)))
 
 (define-command "help" (event-pump message channel)
be6f8571
   (let ((*print-right-margin* (max (or *print-right-margin* 0)
5c15279a
                                    80)))
60e29634
     (with-thread-info (ts thread-ts in-thread is-reply) message
       (format t "~&THREAD INFO: (ts ~s) (thread-ts ~s) (in-thread ~s) (is-reply ~s)~%" ts thread-ts in-thread is-reply)
       (with-output-to-message (s event-pump channel :thread (ensure-thread message))
5c15279a
         (format s "I understand these commands:~%~{`~a`~^ ~}"
                 (hash-table-keys *command-table*))
         :quote t))))
be6f8571
 
 
 (defparameter *id* 0)
 (defun make-message (data channel)
   (incf *id*)
   (with-output-to-string (s)
     (yason:encode
      (alist-hash-table
       `(("id" . ,*id*)
5c15279a
         ("type" . "message")
         ("channel" . ,channel)
         ("text" . ,data)))
be6f8571
      s)))
 
5ea361e0
 
 (in-package :slacker.api)
 
 (slacker::define-api-wrappers
60e29634
   (channels.list () exclude_archived)
5ea361e0
   (chat.delete (ts channel) as_user)
   (chat.me-message (channel text))
811dd7ec
   (chat.post-message (channel text)
                      parse link_name attachments unfurl_links unfurl_media username as_user icon_uri icon_emoji)
5ea361e0
   (chat.update (ts channel text) attachments parse link_names as_user))