(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* "DEL") (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)))