git.fiddlerwoaroof.com
fuka.lisp
62e9d7d6
 (defpackage :hhgbot-fukaws
   (:use :cl :alexandria :serapeum))
 
 (in-package :hhgbot-fukaws)
 
 (eval-when (:compile-toplevel :load-toplevel :execute)
   (ubiquitous:restore 'hhgbot)
   (require 'sb-concurrency))
 
 (defvar *client*)
 (defclass slack-client ()
   ((self :reader self :initarg :self)
    (url :reader url :initarg :url)
    (users :accessor users :initarg :users :initform (make-hash-table :test 'equal))
    (slack-info :reader slack-info :initarg :slack-info)
    (work-mailbox :reader work-mailbox :initform (sb-concurrency:make-mailbox :name "work"))
    (name :reader name)
    (waiting-pings :accessor waiting-pings :initform 0)
    (latest-id :accessor latest-id :initform 0)
    (ws-client :reader ws-client :initarg :ws-client)))
 
 (defmethod initialize-instance :after ((client slack-client) &rest r)
   (declare (ignore r))
   (let ((self (self client)))
     (setf (slot-value client 'name)
           (gethash "name" self))))
 
 (defclass user ()
   ((id :reader id :initarg :id)
    (name :reader name :initarg :name)
    (presence :accessor presence :initarg :presence)
    (deleted :reader deleted :initarg :deleted)
    (color :reader color :initarg :color)
    (profile :reader profile :initarg :profile)
    (is_admin :reader is_admin :initarg :is_admin)
    (is_owner :reader is_owner :initarg :is_owner)
    (is_primary_owner :reader is_primary_owner :initarg :is_primary_owner)
    (is_restricted :reader is_restricted :initarg :is_restricted)
    (is_ultra_restricted :reader is_ultra_restricted :initarg :is_ultra_restricted)
    (has_2fa :reader has_2fa :initarg :has_2fa)
    (two_factor_type :reader two_factor_type :initarg :two_factor_type)
    (has_files :reader has_files :initarg :has_files)))
 
 (defmethod print-object ((o user) s)
   (print-unreadable-object (o s :type t :identity t)
     (format s "~a: ~a" (id o) (name o))))
 
 (defmacro define-constructor (name (class &rest args))
   `(defun ,name (source-hash-table)
      (make-instance ',class
                     ,@(mapcan (lambda (arg) (list (make-keyword arg)
                                                   `(gethash ,(symbol-name arg)
                                                             source-hash-table)))
                               args))))
 
 (define-constructor make-user (user id name deleted color profile is_admin is_owner is_primary_owner is_restricted is_ultra_restricted has_2fa two_factor_type has_files presence))
 
 
 (defparameter *api-token*
df61fef9
   "DEL")
62e9d7d6
 
 (defgeneric send-message (client type &optional data))
 
 (defgeneric handle-message (type message)
   (:documentation "Handle a websocket message")
   (:method (_type message)
    (format t "~& Ok? ~s "(gethash "ok" message))
    (when (eq 'yason:false (gethash "ok" message))
      (format t "~&Problem: ~s~%" (hash-table-alist (gethash "error" message))))
    (format t "Received a packet of type: ~a~%with data: ~s~%" _type
            (hash-table-alist message))))
 
 (defgeneric handle-mention (client event-data id channel message mentioned-pos)
   (:method (client event-data id channel message mentioned-pos)))
 
 (defgeneric bot-command (command &rest args)
   (:method (c &rest r)
     (format t "Received command ~a with args ~s" c r)))
 
 (defun make-attachment (title pretext text)
   (alist-hash-table
     `(("title" . ,title)
       ("pretext" . ,pretext)
       ("text" . ,text))
     :test 'equal))
 
 (defun build-message (id channel text &rest attachments)
  (alist-hash-table 
    `(("id" . ,id)
      ("type" . "message")
      ("channel" . ,channel)
      ("text" . ,text)
      ,@(when attachments
          (cons "attachments"
                (list attachments))))
    :test 'equal))
 
 (let ((id 0))
   (defun make-message (data channel)
     (incf id)
     (with-output-to-string (s)
       (yason:encode
         (alist-hash-table
           `(("id" . ,id)
             ("type" . "message")
             ("channel" . ,channel)
             ("text" . ,data)))
         s))))
 
 (defmethod send-message :around ((client slack-client) _type &optional data)
   (declare (ignorable client _type data))
   (wsd:send (ws-client client)
             (with-output-to-string (s)
               (yason:encode
                 (call-next-method)
                 s))))
 
 (defmethod send-message ((client slack-client) (type (eql :ping)) &optional data)
   (let* ((id (incf (latest-id client)))
          (message `(("id" . ,id)
                     ("type" . "ping"))))
     (when data
       (push (cons "data" data)
             message))
     (incf (waiting-pings client))
     (alist-hash-table message
                       :test 'equal)))
 
 (defun pick (keys h-t)
   (mapcar (plambda:plambda (gethash :1 h-t))
           keys))
 
 (defun quote-output (str)
   (with-output-to-string (s)
     (format s "```~%~a```~%" str)))
 
 (defvar *memory* '())
 
 (defvar *feeds* '("https://thejosias.com/feed"
                   "https://sancrucensis.wordpress.com/feed"
                   "https://thomism.wordpress.com/feed"))
 
 (defvar *books* (ubiquitous:defaulted-value '() :lists :books))
 
 (defclass list-manager ()
   ())
 
 (defgeneric add-to-list (list-name item))
 
 (defmethod add-to-list ((list-name (eql :books)) item)
   (push item
         (ubiquitous:value :lists :books)))
 
 (defmethod get-list ((list-name (eql :books)))
   (ubiquitous:value :lists :books))
 
 (defun get-random-article ()
   (let* ((feed-url (elt *feeds*
                         (funcall (compose #'random #'length)
                                  *feeds*)))
          (feed (alimenta.pull-feed:pull-feed feed-url)))
     (alimenta::get-random-item feed)))
 
 (defmacro if-let* ((&rest bindings) &body (then-form &optional else-form))
   "Like if-let, but sets bindings sequentially.  Doesn't short-circuit."
   `(let* ,bindings
      (if (and ,@(mapcar #'car bindings))
        ,then-form
        ,else-form)))
 
 (defmethod handle-mention ((client slack-client) (event-data hash-table) (id string) (channel string) (message string) (mentioned-pos (eql 0)))
   (declare (optimize (debug 3)))
   (if-let ((message (if (starts-with #\D) (cdr (tokens message)))))
     (let* ((the-user (gethash (gethash "user" event-data)
                               (users client)))
            (msg-text (string-case:string-case ((car message) :default "Not Recognized")
                        ("users"
                         (if (is_admin the-user)
                           (quote-output
                             (with-output-to-string (s)
                               (format-users client s)))
                           "Can't help you"))
                        ("josias"
                         (in-eventloop (cl)
                           (let* ((feed (alimenta.pull-feed:pull-feed "http://thejosias.com/feed"))
                                  (item (alimenta::get-random-item feed)))
                             (wsd:send (ws-client cl)
                                       (make-message (format nil "~a ( ~a )"
                                                             (alimenta:title item)
                                                             (alimenta:link item))
                                                     channel)))))
 
                        ("recommend"
                         (string-case:string-case ((cadr message)
                                                   :default (format nil
                                                                    "I don't know about ~a~p"
                                                                    (cadr message)
                                                                    2))
                           ("book"
                            (wsd:send (ws-client client) 
                                      (make-message
                                        (if-let* ((title (string-join (cddr message) #\space))
                                                  (message (format nil "I'll remember ~a" title)))
                                          (prog1 message
                                            (add-to-list :books title))
                                          "No book suggested???")
                                        channel)))
                           ("feed" (let ((feed (caddr message)))
                                     (push (subseq feed
                                                   1
                                                   (1- (length feed)))
                                         *feeds*)))))
                        ("suggest"
                         (string-case:string-case ((cadr message) :default (format nil "I don't know about ~a~p"
                                                                                   (cadr message) 2))
                           ("book" (let ((*books* (get-list :books)))
                                     (wsd:send (ws-client client)
                                               (make-message (elt *books*
                                                                  (random (length *books*)))
                                                             channel))))
                           ("article" (in-eventloop (cl)
                                        (let ((item (get-random-article)))
                                          (wsd:send (ws-client cl)
                                                    (make-message (format nil "~a ( ~a )"
                                                                          (alimenta:title item)
                                                                          (alimenta:link item))
                                                                  channel)))))))
                        ("list"
                         (string-case:string-case ((cadr message) :default "No such list")
                           ("feeds"
                            (wsd:send (ws-client *client*)
                                      (make-message (format nil "```~%~{~a~^~%~}~%```"
                                                            *feeds*)
                                                    channel)))  
                           ("books"
                            (let ((*books* (ubiquitous:value :lists :books)))
                              (wsd:send (ws-client *client*)
                                        (make-message (format nil "```~%~{~a~^~%~}~%```"
                                                              *books*)
                                                      channel))))))
                        ("remember"
                         (wsd:send (ws-client client)
                                   (make-message (car (push (string-join (cdr message)
                                                                         #\space)
                                                            *memory*))
                                                 channel)))
                        ("recall"
                         (let ((mem-length (length *memory*)))
                           (wsd:send (ws-client client)
                                     (make-message (elt *memory*
                                                        (random mem-length))
                                                   channel)))))))
       (wsd:send (ws-client client)
                 (make-message msg-text channel)))))
 
 (defmethod handle-message ((type (eql :pong)) data)
   (with-accessors ((waiting-pings waiting-pings)) *client*
     (decf waiting-pings)
     (when (> waiting-pings 0)
       (format t "Something wrong? ~a waiting pings" waiting-pings)
       (when (> waiting-pings 5)
         (setf waiting-pings 0)))))
 
 (defmethod handle-message ((type (eql :error)) data)
   (format t "~&~s~%" (hash-table-alist (gethash "error" data))))
 
 (defmethod handle-message ((type (eql :message)) data)
     (format t "~&~s~%" (hash-table-alist data))
   (let* ((message (gethash "text" data))
          (id (gethash "id" (self *client*)))
          (name (name *client*))
          (channel (gethash "channel" data))
          (mentioned (or (search (format nil "<@~a>" id)
                                 message)
                         (search (format nil "~a " name) message)   
                         (search id message))))
     (format t "~&Received a message with text: ~a~&"
             message)
     (format t "~&My id is: ~a~%"
             id)
     (format t "~&The message mentions me? ~a~%"
             mentioned)
     (when mentioned
       (handle-mention *client* data id channel message mentioned))))
 
 (defmethod handle-message ((type (eql :presence_change)) data)
   (let ((id (gethash "user" data))
         (presence (gethash "presence" data)))
     (when-let* ((user (gethash id (users *client*)))
                 (old-presence (presence user))
                 (user-name (name user)))
       (setf (presence user)
             presence)
       (format t "~&Presence change: ~a is now ~a (~a -> ~a)~%"
               user-name
               presence
               old-presence
               (presence user)))))
 
 (defmethod handle-message ((type (eql :team_join)) data)
   (let ((user (gethash "user" data)))
     (when user
       (setf (gethash (gethash "id" user)
                      (users *client*))
             (make-user user))
       (format t "~&Added user: ~a~%" (gethash "id" user)))))
 
 (defun get-ws-url (slack-response)
   (gethash "url" slack-response))
 
 (defun make-client ()
   (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))
            (self (gethash "self" slack-data))
            (users (gethash "users" slack-data))
            (client (wsd:make-client url)))
 
       (wsd:on :message client
               (lambda (message)
                 (let* ((message (yason:parse message
                                              :object-as :hash-table
                                              :json-booleans-as-symbols t))
                        (type (funcall (compose #'make-keyword #'string-upcase)
                                       (gethash "type" message "DEFAULT-TYPE"))))
                   (handle-message type message))))
 
       (make-instance 'slack-client
                      :self self
                      :url url
                      :slack-info slack-data
                      :ws-client client
                      :users (alist-hash-table
                               (loop for user in users
                                     collect (cons (gethash "id" user)
                                                   (make-user (copy-hash-table user :test 'equalp))))
                               :test 'equal)))))
 
 (defun start-heartbeat (client &optional (interval 5))
   (bordeaux-threads:make-thread
     (lambda ()
       (let ((*client* client))
         (loop
           (in-eventloop (*client*)
             (send-message *client* :ping))
           (sleep interval))))
     :name "Heartbeat"))
 
 (defun start-client ()
   (let ((slack-client (make-client)))
     (values
       slack-client
       (bordeaux-threads:make-thread
         (lambda ()
           (let ((*client* slack-client))
             (as:with-event-loop ()
               (websocket-driver.ws.base:start-connection (ws-client slack-client))
               (format t "... after start-connection ...")
               (as:idle
                 (lambda ()
                   (multiple-value-bind (message message-p) (sb-concurrency:receive-message-no-hang (work-mailbox *client*))
                     (when message-p
                       (format t "~&got message~&")
                       (funcall message *client*))))))))
         :name "Server"))))
 
 (defun call-in-eventloop (client cb)
   (sb-concurrency:send-message (work-mailbox client)
                                cb))
 
 (defmacro in-eventloop ((client) &body body)
   `(call-in-eventloop *client*
                       (lambda (,client)
                         (declare (ignorable ,client))
                         ,@body)))
 
 (defun format-users (client &optional (stream t))
   (format stream "~&~:{~a: ~{~19<~a~>~^ ~}~%~}"
           (stable-sort
             (sort
               (loop for id being the hash-keys of (users client) using (hash-value user)
                     collect (list id (list (name user) (presence user))))
               #'string-lessp
               :key #'caadr)
             #'string-lessp
             :key #'cadadr)))