keybase-chat.el/keybase-chat.el

1197 lines
52 KiB
EmacsLisp
Raw Normal View History

;;; keybase-chat --- Keybase chat implementation in Emacs -*- lexical-binding: t -*-
(require 'url)
(require 'subr-x)
(require 'notifications)
(require 'cl)
(require 'keybase-markup)
(defgroup keybase nil
"Keybase chat implementation"
:prefix 'keybase
:group 'applications)
(defcustom keybase--program "keybase"
"The name of the keybase binary"
:type 'string
:group 'keybase)
(defcustom keybase-attribution 'keybase-default-attribution
"A function that prints the attribution before each Keybase message.
It will be given two arguments, the timestamp of the message in seconds since
the epoch and the sender's keybase name."
:type 'function
:group 'keybase)
(defcustom keybase-channel-mode-hook nil
"Hook called by `keybase-channel-mode'"
:type 'hook
:group 'keybase)
(defface keybase-default
()
"Default face for chat buffers."
:group 'keybase)
(defface keybase-message-text-content
'((t
:inherit keybase-default))
"Face used to display the text content of messages."
:group 'keybase)
(defface keybase-message-text-content-bold
'((t
:weight bold
:inherit keybase-default))
"Face used to display bold text."
:group 'keybase)
(defface keybase-message-text-content-italics
'((t
:slant italic
:foreground "#b58900"
:inherit keybase-default))
"Face used to display italics text."
:group 'keybase)
(defface keybase-message-text-content-code
'((((class color))
:background "#f0f0f0"
:inherit keybase-default)
(t
:inherit keybase-default))
"Face used to display code snippets."
:group 'keybase)
(defface keybase-message-text-content-in-progress
'((((class color))
:foreground "#505050"
:inherit keybase-default)
(t
:inherit keybase-default))
"Face used to display the text content of a message that has
not been confirmed from the server yet.")
(defface keybase-message-from
'((((class color))
:foreground "#b58900"
:inherit keybase-default)
(t
:inherit keybase-default))
"Face used to display the 'from' part of a message."
:group 'keybase)
(defface keybase-channel-summary-title
'((t
:weight bold
:inherit keybase-default))
"Face used to display the headlines in the channel list."
:group 'keybase)
(defface keybase-channel-summary-team
'((t
:weight bold
:inherit keybase-default))
"Face used to display the team name in the channel list"
:group 'keybase)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Utilities
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(cl-defun keybase--json-find (obj path &key (error-if-missing t))
(let ((curr obj))
(loop for path-entry in path
for node = (assoc path-entry curr)
unless node
do (if error-if-missing
(error "Node not found in json: %S" path-entry)
(return nil))
do (setq curr (cdr node))
finally (return curr))))
(cl-defmacro keybase--with-json-bind ((&rest defs) json &body body)
(declare (indent 2))
(let ((json-sym (gensym "json")))
`(let ((,json-sym ,json))
(let ,(loop for (sym path) in defs
collect `(,sym (keybase--json-find ,json-sym ',path)))
,@body))))
(cl-defmacro keybase--ensure-hash-value (key hash &body body)
(let ((hash-sym (gensym))
(key-sym (gensym))
(val-sym (gensym))
(default-value-sym (gensym)))
`(let* ((,hash-sym ,hash)
(,key-sym ,key)
(,val-sym (gethash ,key-sym ,hash-sym ',default-value-sym)))
(if (eq ,val-sym ',default-value-sym)
(let ((,val-sym (progn ,@body)))
(setf (gethash ,key-sym ,hash-sym) ,val-sym)
,val-sym)
;; ELSE: The value was found in the hash table
,val-sym))))
(defun keybase--json-parse-result-buffer ()
(let* ((content (buffer-substring (point) (point-max)))
(decoded-content (decode-coding-string content 'utf-8)))
(json-read-from-string decoded-content)))
(defun keybase--url-handler (status buffer callback as-json-p)
(let ((error-status (getf status :error)))
(if error-status
(progn
(message "Got error: %S" status)
(let ((kill-buffer-query-functions nil))
(kill-buffer (current-buffer)))
(signal (car error-status) (cdr error-status)))
;; ELSE: No error
(progn
(goto-char (point-min))
(search-forward "\n\n")
(let ((data (if as-json-p
(potato--keybase-parse-result-buffer)
(buffer-substring (point) (point-max)))))
(with-current-buffer buffer
(funcall callback data))
(let ((kill-buffer-query-functions nil))
(kill-buffer (current-buffer))))))))
(cl-defun keybase--url-retrieve (url method callback &key (as-json-p t) ignore-response)
(let ((buffer (current-buffer)))
(let ((url-request-method method))
(url-retrieve url
(lambda (status)
(unless ignore-response
(keybase--url-handler status buffer callback as-json-p)))
nil t))))
(defun keybase--insert-image-handler (overlay data)
(let ((image (create-image data nil t))
(start (overlay-start overlay))
(end (overlay-end overlay)))
(delete-overlay overlay)
(save-excursion
(let ((inhibit-read-only t))
(goto-char start)
(delete-region start end)
(let ((start (point)))
(insert-image image "[image]"))))))
(defun keybase--insert-image-url-async (url)
"Downloads and insert the image specified by URL at point.
The download is performed in the background, and while
downloading the image, a temporary message is displayed. This
message is then replaced by the image once the download has
finished."
(if url
(let ((start (point)))
(insert "[loading-image]")
(let ((overlay (make-overlay start (point))))
(keybase--url-retrieve url "GET"
(lambda (data)
(keybase--insert-image-handler overlay data))
:as-json-p nil)))
(insert "[no-image]")))
(defun keybase--find-or-make-empty-buffer (name initialiser)
(let ((buffer (get-buffer name)))
(if buffer
(with-current-buffer buffer
(let ((inhibit-read-only t))
(delete-region (point-min) (point-max))
buffer))
(let ((buffer (generate-new-buffer name)))
(with-current-buffer buffer
(funcall initialiser))
buffer))))
(defvar keybase-button-keymap
(let ((map (make-sparse-keymap)))
(define-key map [mouse-2] 'keybase-open-selected-button)
(define-key map (kbd "RET") 'keybase-open-selected-button)
map))
(defun keybase-open-selected-button ()
(interactive)
(let ((callback (get-char-property (point) 'button-function))
(data (get-char-property (point) 'button-data)))
(funcall callback data)))
(cl-defun keybase--make-clickable-button (message function data)
(propertize message
'font-lock-face 'link
'keymap keybase-button-keymap
'mouse-face 'highlight
'button-function function
'button-data data))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Channel tools
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defvar keybase-channel-link-keymap
(let ((map (make-sparse-keymap)))
(define-key map [mouse-2] 'keybase-open-selected-channel)
(define-key map (kbd "RET") 'keybase-open-selected-channel)
map))
(defun keybase--make-channel-button (name channel)
(propertize name
'font-lock-face 'link
'mouse-face 'highlight
'help-echo (format "mouse-2: open channel buffer")
'keybase-channel-name channel
'keymap keybase-channel-link-keymap))
(defun keybase-open-selected-channel ()
(interactive)
(when-let ((channel (get-char-property (point) 'keybase-channel-name)))
(keybase-join-channel channel)))
(defvar keybase-username-link-keymap
(let ((map (make-sparse-keymap)))
(define-key map [mouse-2] 'keybase-open-selected-username)
(define-key map (kbd "RET") 'keybase-open-selected-username)
map))
(defun keybase-open-selected-username ()
(interactive)
(when-let ((user (get-char-property (point) 'keybase-user)))
(keybase-user-info user)))
(cl-defun keybase--make-clickable-username (name &key include-prefix highlight)
(apply #'propertize (format "%s%s" (if include-prefix "@" "") name)
'font-lock-face '(:foreground "red") ;; 'link
'help-echo (format "mouse-2: open user info for %s" name)
'keybase-user name
'keymap keybase-username-link-keymap
(if highlight
(list 'mouse-face 'highlight)
nil)))
(defun keybase--private-conversation-channel-name (user)
(let ((current-name (with-current-buffer keybase--proc-buf keybase--username)))
(list "impteamnative" (if (string< current-name user)
(format "%s,%s" current-name user)
(format "%s,%s" user current-name))
nil)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Channel mode
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defvar keybase--proc-buf nil)
(defvar keybase--active-buffers nil
"List of active channels.
Each entry is of the form (CHANNEL-INFO . BUFFER)")
(defvar keybase--channels nil
"List of channels.
Each entry is of the form (CHANNEL-INFO UNREAD")
(defvar keybase-channel-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "<S-return>") 'keybase-insert-nl)
(define-key map (kbd "RET") 'keybase-send-input-line)
;;(define-key map (kbd "@") 'keybase-insert-user)
(define-key map (kbd "C-c C-d") 'keybase-delete-message)
(define-key map [menu-bar keybase] (cons "Keybase" (make-sparse-keymap "Keybase")))
(define-key map [menu-bar keybase join-channel] '("Join channel" . keybase-join-channel))
(define-key map [menu-bar keybase create-private-conversation] '("Private conversation" . keybase-create-private-converstion))
(define-key map [menu-bar keybase show-user-info] '("User info" . keybase-user-info))
(define-key map [menu-bar keybase delete-message] '("Delete message" . keybase-delete-message))
map))
(defun keybase--load-more-messages-handler (data)
(keybase-load-messages))
(define-derived-mode keybase-channel-mode nil "Keybase"
"Mode for Keybase channel content"
(use-local-map keybase-channel-mode-map)
(insert (keybase--make-clickable-button "[Load more messages]" #'keybase--load-more-messages-handler nil))
(insert "\n\n")
(setq-local keybase--start-of-messages-marker (make-marker))
(set-marker keybase--start-of-messages-marker (point))
(setq-local keybase--output-marker (make-marker))
(setq-local keybase--input-marker (make-marker))
(set-marker keybase--output-marker (point-max))
(insert "channel> ")
(add-text-properties (point-at-bol) (point)
(list 'read-only t
'rear-nonsticky t
'front-sticky '(read-only)
'inhibit-line-move-field-capture t
'field 'output))
(set-marker-insertion-type keybase--output-marker t)
(set-marker keybase--input-marker (point-max)))
(defun keybase--read-input-line (start end)
(let ((uid-refs (loop for overlay in (overlays-in start end)
for uid = (overlay-get overlay 'keybase-user-ref)
when uid
collect (list (overlay-start overlay) (overlay-end overlay) uid overlay))))
(with-output-to-string
(loop with p = start
for uid-ref in (sort uid-refs (lambda (a b) (< (first a) (first b))))
if (< p (first uid-ref))
do (princ (buffer-substring p (first uid-ref)))
do (progn
(error "uid-refs not implemented")
(princ (format "\U000f0001user:%s:%s\U000f0001"
(third uid-ref) (buffer-substring (first uid-ref) (second uid-ref))))
(setq p (second uid-ref))
(delete-overlay (fourth uid-ref)))
finally (when (< p end)
(princ (buffer-substring p end)))))))
(defun keybase-insert-nl ()
"Insert a newline into the message."
(interactive)
(insert "\n"))
(defun keybase-insert-user ()
"Select a username to be inserted into the new message."
(interactive)
(error "can't insert user"))
(defun keybase-delete-message ()
(interactive)
(let ((msgid (keybase--find-message-at-point (point))))
(if msgid
(when (yes-or-no-p "Really delete message? ")
(keybase--request-chat-api `((method . "delete")
(params . ((options . ((channel . ,(keybase--channel-info-as-json keybase--channel-info))
(message_id . ,msgid))))))))
;; ELSE: No message at point
(message "No message at point"))))
(defun keybase--buffer-closed ()
(setq keybase--active-buffers (cl-remove (current-buffer) keybase--active-buffers :key #'cdr :test #'eq)))
(defun keybase--generate-channel-name (channel-info)
(if (third channel-info)
(format "*keybase %s - %s*" (second channel-info) (third channel-info))
(format "*keybase %s*" (second channel-info))))
(defun keybase--window-config-updated ()
"Hook function that is locally installed for window-configuration-change-hook in all channel buffers."
(let ((recompute nil))
(when (get-buffer-window)
;; Clear unread count
(when (plusp keybase--unread-in-channel)
(setq recompute t)))
(when recompute
(keybase--recompute-modeline))))
(defvar keybase--mark-unread-in-progress nil)
(defvar keybase--mark-unread-pending nil)
(defun keybase--find-most-recent-message ()
(let ((pos (previous-single-char-property-change (point-max) 'keybase-remote-message-id)))
(if pos
(get-char-property (1- pos) 'keybase-remote-message-id)
nil)))
(defun keybase--mark-unread-start-process ()
(when keybase--mark-unread-in-progress
(error "Mark unread already in progress"))
(if-let ((msgid (keybase--find-most-recent-message)))
(let ((proc (keybase--request-api-async keybase--program
'("chat" "api")
`((method . "mark")
(params . ((options . ((channel . ,(keybase--channel-info-as-json keybase--channel-info)))))))
(lambda (json)
(setq keybase--mark-unread-in-progress nil)
(keybase--mark-pending-unread)))))
(setq keybase--mark-unread-in-progress proc)
t)
;; ELSE: Return nil to indicate that a process was not started
nil))
(defun keybase--mark-pending-unread ()
(loop while keybase--mark-unread-pending
until (let ((req (car keybase--mark-unread-pending)))
(setq keybase--mark-unread-pending (cdr keybase--mark-unread-pending))
(with-current-buffer req
(keybase--mark-unread-start-process)))))
(defun keybase--mark-unread ()
(when (plusp keybase--unread-in-channel)
(setq keybase--unread-in-channel 0)
(if keybase--mark-unread-in-progress
(cl-pushnew (current-buffer) keybase--mark-unread-pending)
(keybase--mark-unread-start-process))))
(defun keybase--process-buffer-list-update ()
(unless (eq major-mode 'keybase-channel-mode)
(error "This function should only be called with channel buffers"))
(when (eq (current-buffer) (car (buffer-list)))
(keybase--mark-unread)))
(defun keybase--create-buffer (channel-info)
;; First ensure that the listener is running
(keybase--find-process-buffer)
;; Create the buffer
(let ((buffer (generate-new-buffer (keybase--generate-channel-name channel-info))))
(with-current-buffer buffer
(keybase-channel-mode)
(setq-local keybase--channel-info channel-info)
(setq-local keybase--unread-in-channel 0)
(add-hook 'kill-buffer-hook 'keybase--buffer-closed nil t)
(add-hook 'buffer-list-update-hook 'keybase--process-buffer-list-update nil t)
(push (cons channel-info buffer) keybase--active-buffers)
(setq-local keybase--next-tag nil)
(keybase-load-messages 10))
(unless (member 'keybase-display-notifications-string global-mode-string)
(if global-mode-string
(setq global-mode-string (append global-mode-string '(keybase-display-notifications-string)))
(setq global-mode-string '("" keybase-display-notifications-string)))
(keybase--recompute-modeline))
buffer))
(cl-defun keybase--find-channel-buffer (channel-info &key (if-missing :error))
(unless (member if-missing '(:error :create :ignore))
(error "Illegal argument to if-missing: %S" if-missing))
(let ((e (find channel-info keybase--active-buffers :key #'car :test #'equal)))
(cond (e
(cdr e))
((eq if-missing :create)
(keybase--create-buffer channel-info))
((eq if-missing :error)
(error "No buffer for channel %S" channel-info)))))
(cl-defun keybase-load-messages (&optional (num 10))
"Load NUM messages from the message history."
(interactive)
(let* ((messages-json (keybase--request-chat-api `((method . "read")
(params . ((options . ((channel . ,(keybase--channel-info-as-json keybase--channel-info))
(pagination . (,@(if keybase--next-tag
`((next . ,keybase--next-tag))
nil)
(num . ,num))))))))))
(next-tag (keybase--json-find messages-json '(result pagination next)))
(messages (keybase--json-find messages-json '(result messages))))
(setq keybase--next-tag next-tag)
(loop for msg-entry across messages
for msg = (keybase--json-find msg-entry '(msg))
for id = (keybase--json-find msg '(id))
for sender = (keybase--json-find msg '(sender username))
for timestamp = (keybase--json-find msg '(sent_at_ms))
for content = (keybase--json-find msg '(content))
for type = (keybase--json-find content '(type))
when (equal type "text")
do (keybase--insert-message id timestamp sender (keybase--json-find content '(text body)) nil))))
(defun keybase--format-date (timestamp)
(let ((time (seconds-to-time (/ timestamp 1000))))
(format-time-string "%Y-%m-%d %H:%M:%S" time)))
(defun keybase--format-simple-date (timestamp)
(let ((time (seconds-to-time (/ timestamp 1000))))
(format-time-string "%H:%M" time)))
(defun keybase--recompute-modeline ()
(setq keybase-display-notifications-string (keybase--make-unread-notification-string))
(force-mode-line-update t))
(defun keybase--make-unread-notification-string ()
(with-output-to-string
(princ "Unread: ")
(let ((unread-channels (loop with first = t
for channel in keybase--active-buffers
for (name unread) = (with-current-buffer (cdr channel)
(list keybase--channel-info keybase--unread-in-channel))
when (plusp unread)
collect name)))
(if unread-channels
(loop for first = t then nil
for name in unread-channels
unless first
do (princ ", ")
do (princ (keybase--generate-channel-name name)))
;; ELSE: Just return the empty string
""))))
(defun keybase-default-attribution (sender timestamp)
(format "[%s] %s "
(keybase--make-clickable-username sender :highlight nil)
(keybase--format-simple-date timestamp)))
(defvar keybase--first-paragraph nil)
(defun keybase--render-markup-element (element)
(etypecase element
(string (insert element))
(list (if (stringp (car element))
(insert (car element))
(ecase (car element)
(:paragraph
(let ((col (current-column))
(wrapcol fill-column))
(let ((content (with-temp-buffer
(keybase--insert-markup-inner (cdr element))
(let ((sentence-end-double-space nil)
(fill-column (- wrapcol col)))
(fill-region (point-min) (point-max))
(buffer-string)))))
(loop for v in (keybase--split-with-regexp "\n" content :empty t)
for first = t then nil
unless first
do (loop repeat col
do (insert " "))
do (progn
(insert v)
(insert "\n"))))))
(:newline
(insert "\n"))
(:bold
(let ((start (point)))
(keybase--insert-markup-inner (cdr element))
(add-text-properties start (point) (list 'face 'keybase-message-text-content-bold))))
(:italics
(let ((start (point)))
(keybase--insert-markup-inner (cdr element))
(add-text-properties start (point) (list 'face 'keybase-message-text-content-italics))))
(:code
(insert (propertize (cdr element) 'face 'keybase-message-text-content-code)))
(:code-block
(insert "\n")
(insert (propertize (third element) 'face 'keybase-message-text-content-code))
(insert "\n"))
(:user
(insert (keybase--make-clickable-username (cdr element) :include-prefix t :highlight t))))))))
(defun keybase--insert-markup-inner (content)
(loop for v in content
do (keybase--render-markup-element v)))
(defun keybase--parse-user (string fn)
(let ((pos 0)
(length (length string))
(result nil))
(cl-labels ((append-res (elems) (setq result (append result elems)))
(collect-part (v) (when (< pos v) (setq result (append result (funcall fn (subseq string pos v)))))))
(loop while (< pos length)
do (let ((result (string-match "@\\([a-z0-9_]+\\)\\(?:$\\|\\W\\)" string pos)))
(if result
(let ((user (match-string 1 string))
(end-pos (match-end 1)))
(collect-part result)
(append-res `((:user . ,user)))
(setq pos end-pos))
;; ELSE: No more results
(collect-part length)
(setq pos length))))
result)))
(defun keybase--insert-markup-string (message)
(let ((content (let ((keybase--custom-parser-3 #'keybase--parse-user))
(keybase--markup-paragraphs message :allow-nl t))))
(let ((keybase--first-paragraph t))
(keybase--insert-markup-inner content))))
(defvar keybase--current-id 0)
(defun keybase--make-in-progress-id ()
(format "temp-%d" (incf keybase--current-id)))
(defun keybase--insert-message-content (id timestamp sender message image)
"Insert message content at the current cursor position.
ID may be nil, in which case this message represents an
in-progress message which is inserted while a new message is
being inserted. It will later be replaced with the real content
once it is received from the server."
(let ((inhibit-read-only t))
(let ((start (point)))
(insert (propertize (funcall keybase-attribution sender timestamp)
'face 'keybase-message-from))
(let ((text-start (point)))
(when (> (length message) 0)
(keybase--insert-markup-string message)
;; (insert "\n")
)
(when image
(destructuring-bind (image-title image-filename)
image
(keybase--insert-image image-title image-filename)
(insert "\n")))
(let ((gen-id (or id (keybase--make-in-progress-id))))
(add-text-properties start (point)
(append (list 'read-only t
'keybase-message-id gen-id
'keybase-timestamp timestamp
'keybase-sender sender
'front-sticky '(read-only))
(if (null id)
(list 'keybase-in-progress gen-id
'keybase-content message
'face 'keybase-message-text-content-in-progress)
(list 'keybase-remote-message-id id)))))))))
(defun keybase--insert-message (id timestamp sender message image)
(save-excursion
(goto-char keybase--output-marker)
(let ((new-pos (loop with prev-pos = (point)
for pos = (previous-single-char-property-change prev-pos 'keybase-timestamp)
do (let ((prop (get-char-property pos 'keybase-timestamp)))
(when (and prop (< prop timestamp))
(return prev-pos)))
do (when (<= pos (marker-position keybase--start-of-messages-marker))
(return (marker-position keybase--start-of-messages-marker)))
do (setq prev-pos pos)
finally (return prev-pos))))
(goto-char new-pos)
(keybase--insert-message-content id timestamp sender message image))))
(defun keybase--find-message-in-log (id)
(loop with curr = (point-min)
for pos = (next-single-property-change curr 'keybase-message-id)
while pos
for value = (get-char-property pos 'keybase-message-id)
when (equal value id)
return (list pos (next-single-property-change pos 'keybase-message-id))
do (setq curr pos)
finally (return nil)))
(defun keybase--find-message-at-point (pos)
(get-char-property pos 'keybase-message-id))
(defun keybase--handle-post-message (json)
(let ((id (keybase--json-find json '(id)))
(message (keybase--json-find json '(content text body)))
(sender (keybase--json-find json '(sender username)))
(timestamp (keybase--json-find json '(sent_at_ms))))
;; If this message is sent by us, we need to check if there is an
;; in-progress message inserted in the buffer. If so, it beeds to
;; be removed before the real one is sent.
(when (equal sender (with-current-buffer keybase--proc-buf keybase--username))
(save-excursion
(loop with curr = (point-min)
for pos = (next-single-property-change curr 'keybase-in-progress)
while pos
when (and (get-char-property pos 'keybase-in-progress)
(equal (get-char-property pos 'keybase-content) message))
do (let ((end (next-single-property-change pos 'keybase-message-id))
(inhibit-read-only t))
(delete-region pos end)
(return nil))
do (setq curr pos))))
(keybase--insert-message id timestamp sender message nil)
(let ((old keybase--unread-in-channel))
(incf keybase--unread-in-channel)
(when (zerop old)
(keybase--recompute-modeline)))))
(defun keybase--delete-message (id)
(message "deleting message %S" id)
(let ((old-message-pos (keybase--find-message-in-log id)))
(when old-message-pos
(let ((inhibit-read-only t))
(delete-region (first old-message-pos) (second old-message-pos))))))
(defun keybase--handle-delete (json)
(let ((message-list (keybase--json-find json '(content delete messageIDs))))
(loop for id across message-list
do (keybase--delete-message id))))
(defun keybase--handle-edit (json)
(let* ((old-msgid (keybase--json-find json '(content edit messageID)))
(old-message-pos (keybase--find-message-in-log old-msgid)))
;; If the message isn't already displayed, we don't need to do
;; anything (we don't want old messages added just because someone
;; edited them)
(when old-message-pos
(destructuring-bind (old-message-start old-message-end)
old-message-pos
(save-excursion
(let* ((msg old-message-start)
(old-timestamp (get-char-property msg 'keybase-timestamp)))
(unless old-timestamp
(error "no timestamp for previous message"))
(let ((inhibit-read-only t))
(delete-region old-message-start old-message-end))
;; An UPDATE message contains the same fields as a TEXT message.
(let ((message (keybase--json-find json '(content edit body)))
(sender (keybase--json-find json '(sender username))))
(goto-char old-message-start)
(keybase--insert-message-content msg old-timestamp sender message nil))))))))
(defvar *keybase--attachment-type-none* 0)
(defvar *keybase--attachment-type-image* 1)
(defvar *keybase--attachment-type-video* 2)
(defvar *keybase--attachment-type-audio* 3)
(defun keybase--file-to-extension (file)
"Returns the extension for the given FILE, or null if the file does not have an extension."
(let ((result (string-match "^.*\\.\\([^.]+\\)$" file)))
(if result
(match-string 1 file)
nil)))
(defun keybase--insert-image (title filename)
(let ((image-data (with-temp-buffer
(insert-file-contents-literally filename)
(decode-coding-string (buffer-string) 'no-conversion))))
(let ((image (create-image image-data nil t)))
(insert-image image "[image]"))))
(defun keybase--handle-image-message (json)
(let* ((id (keybase--json-find json '(id)))
(sender (keybase--json-find json '(sender username)))
(timestamp (keybase--json-find json '(sent_at)))
(attachment (keybase--json-find json '(content attachment)))
(asset-type (keybase--json-find attachment '(object metadata assetType))))
(when (eql asset-type *keybase--attachment-type-image*)
(let* ((filename (keybase--json-find attachment '(object filename)))
(content-type (keybase--json-find attachment '(object mimeType)))
(title (keybase--json-find attachment '(object title)))
(file (make-temp-file "emacs-keybase" nil (format ".%s" (keybase--file-to-extension filename)))))
(unwind-protect
(progn
(keybase--request-chat-api `((method . "download")
(params . ((options . ((channel . ,(keybase--channel-info-as-json keybase--channel-info))
(message_id . ,id)
(output . ,file)))))))
(keybase--insert-message id timestamp sender "Uploaded file" (list title file)))
(delete-file file))))))
(cl-defun keybase--handle-incoming-chat-message (json)
(let ((msg (keybase--json-find json '(msg) :error-if-missing nil)))
(when msg
(let* ((channel-info (keybase--parse-channel-name (keybase--json-find msg '(channel))))
(buffer (keybase--find-channel-buffer channel-info :if-missing :ignore)))
(when buffer
(with-current-buffer buffer
(let ((type (keybase--json-find msg '(content type))))
(cond ((equal type "text")
(keybase--handle-post-message msg))
((equal type "delete")
(keybase--handle-delete msg))
((equal type "edit")
(keybase--handle-edit msg))
((equal type "attachment")
(keybase--handle-image-message msg))))))
;; We need to check mentions for all channels, not just the ones the user have opened
(let ((at-mention-usernames (keybase--json-find msg '(at_mention_usernames) :error-if-missing nil))
(username (with-current-buffer keybase--proc-buf keybase--username)))
(when (loop for v across at-mention-usernames
when (equal v username)
return t)
(message "mention in channel: %S" channel-info)))))))
(defun keybase--request-api (command command-args arg)
(let ((output-buf (generate-new-buffer " *keybase api*")))
(unwind-protect
(progn
(if arg
(with-temp-buffer
(insert (json-encode arg))
(apply #'call-process-region (point-min) (point-max) command nil (list output-buf nil) nil command-args))
(apply #'call-process command nil (list output-buf nil) nil command-args))
(with-current-buffer output-buf
(goto-char (point-min))
(json-read)))
(kill-buffer output-buf))))
(cl-defun keybase--request-api-async (command command-args arg callback &key buffer)
(let ((output-buf (generate-new-buffer " *keybase api*")))
(let ((proc (make-process :name "keybase-api"
:command (cons command command-args)
:buffer (or buffer output-buf)
:stderr " *keybase api error*"
:coding 'utf-8
:filter (lambda (proc s) (with-current-buffer output-buf (insert s)))
:sentinel (lambda (proc type)
(when (string-match "^\\(finished\\|deleted\\|exited\\|failed\\)" type)
(unwind-protect
(when (string-match "^finished" type)
(with-current-buffer output-buf
(goto-char (point-min))
(let ((result (json-read)))
(funcall callback result))))
(kill-buffer output-buf)))))))
(when arg
(let ((encoded (json-encode arg)))
(process-send-string proc encoded))
(process-send-eof proc)
proc))))
(defun keybase--request-chat-api (arg)
(keybase--request-api keybase--program '("chat" "api") arg))
(defun keybase--channel-info-as-json (channel-info)
(append (if (first channel-info) `((members_type . ,(first channel-info))) nil)
(if (third channel-info) `((topic_name . ,(third channel-info))) nil)
`((name . ,(second channel-info))
(topic_type . "chat"))))
(defun keybase--parse-channel-name (json)
(let ((members-type (keybase--json-find json '(members_type) :error-if-missing nil))
(name (keybase--json-find json '(name)))
(topic-name (keybase--json-find json '(topic_name) :error-if-missing nil)))
(list members-type name topic-name)))
(defun keybase--list-channels (&optional force-reload)
(if (and keybase--channels (not force-reload))
keybase--channels
(keybase--reload-channels)))
(defun keybase--reload-channels ()
(let ((result (keybase--request-chat-api '((method . "list")))))
(let ((channels (loop for conversation across (keybase--json-find result '(result conversations))
for channel-name = (keybase--parse-channel-name (keybase--json-find conversation '(channel)))
for unread = (not (eq (keybase--json-find conversation '(unread)) :json-false))
collect (list channel-name unread))))
(setq keybase--channels channels)
channels)))
(defun keybase--input (str)
(unless keybase--channel-info
(error "No channel info available in this buffer"))
(save-excursion
(goto-char keybase--output-marker)
(keybase--insert-message-content nil
(time-to-seconds (current-time))
(with-current-buffer keybase--proc-buf keybase--username)
str
nil))
(keybase--request-api-async keybase--program
(list "chat" "api")
`((method . "send")
(params . ((options . ((channel . ,(keybase--channel-info-as-json keybase--channel-info))
(message . ((body . ,str))))))))
(lambda (json)
nil)))
(defun keybase-send-input-line ()
"Send the currently typed line to the server."
(interactive)
(let ((text (string-trim (keybase--read-input-line keybase--input-marker (point-max)))))
(when (not (equal text ""))
(delete-region keybase--input-marker (point-max))
(keybase--input text))))
(cl-defun keybase--filter-command (proc output)
;; Hack to skip the initial status message. This message is sent on
;; stderr so it should never be seen, but this function is still
;; called when it's added.
(when (string-match "^Listening for chat notifications" output)
(return-from keybase--filter-command nil))
;;
(with-current-buffer (process-buffer proc)
(save-excursion
;; Add the output to the buffer
(goto-char (point-max))
(insert output)
;; Parse any completed messages
(goto-char (point-min))
(loop with pos = (point)
for nl = (search-forward-regexp "\n" nil t)
while nl
do (let ((content (buffer-substring pos nl)))
(condition-case err
(progn
(keybase--handle-incoming-chat-message (json-read-from-string content))
(setq pos nl))
(json-readtable-error
(message "ate bad json: %S" content)
(setq pos nl)))))
(delete-region (point-min) (point)))))
(defun keybase--connect-to-server ()
(let ((name " *keybase server*"))
;; Ensure that there is no buffer with this name already
(when (get-buffer name)
(error "keybase server buffer already exists"))
(let* ((buf (get-buffer-create name))
(pipe (make-pipe-process :name "keybase server error output"
:buffer buf
:filter (lambda (proc output)
;; Ignore error output
nil))))
(let ((proc (make-process :name "keybase server"
:buffer buf
:command '("keybase" "chat" "api-listen")
:coding 'utf-8
:filter 'keybase--filter-command
:stderr pipe)))
(with-current-buffer buf
(let ((json (keybase--request-api keybase--program '("status" "--json") nil)))
(setq-local keybase--server-process proc)
(setq-local keybase--username (keybase--json-find json '(Username)))))
(setq keybase--proc-buf buf)
buf))))
(defun keybase--find-active-process-buffer ()
(when keybase--proc-buf
(if (buffer-live-p keybase--proc-buf)
(with-current-buffer keybase--proc-buf
(if (process-live-p keybase--server-process)
keybase--proc-buf
(progn
(kill-buffer keybase--proc-buf)
(setq keybase--proc-buf nil)
nil)))
(progn
(setq keybase--proc-buf nil)
nil))))
(defun keybase--find-process-buffer ()
(let ((buf (keybase--find-active-process-buffer)))
(or buf (keybase--connect-to-server))))
(defun keybase--disconnect-from-server ()
(let ((buf (keybase--find-active-process-buffer)))
(when buf
(kill-buffer buf)
(setq keybase--proc-buf nil))))
(defun keybase--choose-channel-info ()
(let ((channels (keybase--list-channels)))
(destructuring-bind (names-list names-ref)
(loop for e in channels
for channel = (first e)
for topic-name = (third channel)
for name = (if topic-name
(format "%s/%s" (second channel) (third channel))
(second channel))
collect name into names-list
collect (list name channel) into id-list
finally (return (list names-list id-list)))
(let ((result (completing-read "Channel: " names-list nil t nil nil nil nil)))
(unless result
(error "No channel was selected"))
(let ((found (cl-find result names-ref :key #'first :test #'equal)))
(unless found
(error "Selected channel did not match one of available names"))
(second found))))))
(defun keybase-join-channel (channel-info)
(interactive (list (keybase--choose-channel-info)))
(let ((buf (keybase--find-channel-buffer channel-info :if-missing :create)))
(switch-to-buffer buf)))
(defun keybase-create-private-converstion (user)
(interactive (let* ((v (get-char-property (point) 'keybase-user))
(default-name (or v (get-char-property (point) 'keybase-sender))))
(let ((name (read-string (if default-name
(format "User (default %s): " default-name)
"User: ")
nil nil default-name nil)))
(list name))))
(keybase-join-channel (keybase--private-conversation-channel-name user)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Channel summary
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defvar keybase-conversations-list-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "g") 'keybase-conversations-refresh)
map))
(define-derived-mode keybase-conversations-list-mode fundamental-mode "Keybase conversations"
"Major mode for displaying the keymap help."
(use-local-map keybase-conversations-list-mode-map)
(read-only-mode 1)
(setq truncate-lines t))
(defun keybase--sort-team-list (channels)
(let ((team-list (make-hash-table :test 'equal)))
(loop for channel in channels
for channel-name = (first channel)
when (equal (first channel-name) "team")
do (let* ((team-name (second channel-name))
(name (third channel-name))
(v (gethash team-name team-list :unset)))
(if (eq v :unset)
(setf (gethash team-name team-list) (list channel))
(setf (gethash team-name team-list) (cons channel v)))))
team-list))
(defun keybase--render-team-list (channels)
(let ((team-list (keybase--sort-team-list channels)))
(insert (propertize "Teams\n" 'face 'keybase-channel-summary-title))
(let ((team-names (sort (loop for name being each hash-key in team-list collect name) #'string<)))
(loop for name in team-names
for channel-list = (gethash name team-list)
do (progn
(insert "\n ")
(insert (propertize name 'face 'keybase-channel-summary-team))
(insert "\n")
(loop for (channel unread) in (sort channel-list (lambda (a b) (string< (third (car a)) (third (car b)))))
do (progn
(insert " ")
(insert (keybase--make-channel-button (third channel) channel))
(when unread
(insert " (unread)"))
(insert "\n"))))))))
(defun keybase--render-private-list (channels)
(insert "\n")
(insert (propertize "Private conversations\n\n" 'face 'keybase-channel-summary-title))
(loop for (channel-name unread) in channels
when (equal (first channel-name) "impteamnative")
do (progn
(insert (second channel-name))
(insert "\n"))))
(defun keybase-conversations-refresh ()
(interactive)
(unless (eq major-mode 'keybase-conversations-list-mode)
(error "Buffer is not a keybase-conversations-list"))
(let ((buffer (current-buffer)))
(let ((inhibit-read-only t))
(delete-region (point-min) (point-max))
(insert "Loading conversations...\n")
(let ((overlay (make-overlay (point-min) (point-max))))
(keybase--request-api-async keybase--program '("chat" "api") '((method . "list"))
(lambda (json)
(with-current-buffer buffer
(let ((inhibit-read-only t)
(start (overlay-start overlay))
(end (overlay-end overlay)))
(goto-char start)
(delete-region start end)
(delete-overlay overlay)
(let ((channels (keybase--list-channels))
(team-list (make-hash-table :test 'equal))
(private-list (make-hash-table :test 'equal)))
(keybase--render-team-list channels)
(keybase--render-private-list channels)))))
:buffer (current-buffer))))))
(defun keybase-list-conversations ()
(interactive)
(let ((buffer (get-buffer "*keybase conversations*")))
(when (and buffer (not (eq (with-current-buffer buffer major-mode) 'keybase-conversations-list-mode)))
(error "Conversation lists buffer already exists but has the wrong mode"))
(unless buffer
(setq buffer (get-buffer-create "*keybase conversations*")))
(with-current-buffer buffer
(keybase-conversations-list-mode)
(keybase-conversations-refresh))
(pop-to-buffer buffer)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Search
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defvar keybase-search-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") 'quit-window)
map))
(define-derived-mode keybase-search-mode nil "Keybase search"
"Mode for buffers containing keybase search results"
(use-local-map keybase-search-map))
(defun keybase--format-search-results (json)
(let ((hits (keybase--json-find json '(result hits))))
(loop for entry across hits
do (let ((msg (keybase--json-find entry '(hitMessage))))
(keybase--with-json-bind ((message-id (valid messageID))
(ctime (valid ctime))
(sender-username (valid senderUsername))
(message-type (valid messageBody messageType)))
msg
(when (eql message-type 1)
(let ((text (keybase--json-find msg '(valid messageBody text body))))
(keybase--insert-message-content message-id ctime sender-username text nil))))))))
(defun keybase--make-search-buffer ()
(let ((buffer-name "*keybase search*"))
(if-let ((buffer (get-buffer buffer-name)))
(with-current-buffer buffer
(let ((inhibit-read-only t))
(delete-region (point-min) (point-max))
buffer))
;; ELSE: Need to create the buffer
(let ((buffer (generate-new-buffer buffer-name)))
(with-current-buffer buffer
(keybase-search-mode)
(read-only-mode 1))
buffer))))
(defun keybase-search-channel (query)
(interactive "sQuery: ")
(unless (eq major-mode 'keybase-channel-mode)
(error "Not a channel buffer"))
(let ((buffer (keybase--make-search-buffer))
(channel-info keybase--channel-info))
(with-current-buffer buffer
(let ((inhibit-read-only t))
(insert "Loading results\n"))
(keybase--request-api-async keybase--program
'("chat" "api")
`((method . "searchregexp")
(params . ((options . ((channel . ,(keybase--channel-info-as-json channel-info))
(query . ,query)
(is_regex . t))))))
(lambda (json)
(with-current-buffer buffer
(let ((inhibit-read-only t))
(delete-region (point-min) (point-max))
(keybase--format-search-results json))))
:buffer (current-buffer)))
(pop-to-buffer buffer)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; User information
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defvar keybase-user-info-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") 'quit-window)
map))
(define-derived-mode keybase-user-info-mode nil "Keybase user info"
(use-local-map keybase-user-info-map))
(defun keybase--private-conversation-handler (user)
(keybase-join-channel (keybase--private-conversation-channel-name user)))
(defun keybase--fill-in-user-lookup-results (json)
(let ((userlist (keybase--json-find json '(them))))
(unless (= (length userlist) 1)
(error "Only able to fill in user info when the result has a single user"))
(let ((user-result (aref userlist 0)))
(keybase--with-json-bind
((user (basics username))
(full-name (profile full_name))
(bio (profile bio))
(location (profile location))
(primary-image (pictures primary url)))
user-result
(keybase--insert-image-url-async primary-image)
(insert "\n")
(insert (propertize "Name: " 'face 'bold) user "\n")
(insert (propertize "Full name: " 'face 'bold) full-name "\n")
(insert (propertize "Location: " 'face 'bold) location "\n")
(insert (propertize "Bio: " 'face 'bold) bio "\n\n")
(insert (keybase--make-clickable-button "Start private conversation" #'keybase--private-conversation-handler user))
(insert "\n")))))
(defun keybase--start-load-user-info (user)
(let ((buffer (current-buffer)))
(keybase--request-api-async keybase--program
(list "apicall" "-a" (format "usernames=%s" user) "user/lookup")
nil
(lambda (json)
(with-current-buffer buffer
(let ((inhibit-read-only t))
(keybase--fill-in-user-lookup-results json)))))))
(defun keybase-user-info (user)
(interactive "sUsername: ")
(let ((buffer (keybase--find-or-make-empty-buffer "*keybase user info*"
(lambda ()
(keybase-user-info-mode)
(read-only-mode 1)))))
(with-current-buffer buffer
(keybase--start-load-user-info user))
(pop-to-buffer buffer)))
(provide 'keybase-chat)