Using rcirc with ZNC playback module

(Skip to the end for the full source)

The ZNC playback module uses IRCv3 capability negotiation and server-time to allow clients to only request playback they have not yet seen.

The playback flow

  1. Request playback capability before authenticating.
    > CAP REQ znc.in/playback
    > NICK ...
    > USER ...
  2. Wait for the server to ACK your request, verifying that it has the module loaded. Once you receive the ACK, you can request all the logs since the last time you received a message, passed as a timestamp.
    < CAP ACK znc.in/playback
    > PRIVMSG *playback :Play * 1493250581
  3. Store the timestamp in the tag of each new message you see, making it "read".
    < @time=1493250612 :... PRIVMSG #channel :Heya

Unfortunately, rcirc does not support any of this. Rcirc isn't known for its extensibility, it only supports a few hooks, so you have to use advice to modify the relevant functions.

The hack

First things first, during connection rcirc needs to request the playback capability. Since this can't happen before or after rcirc-connect, I resorted to advising rcirc-send-string so that it will send "CAP REQ znc.in/playback" before sending a message starting with "USER ".

(advice-add 'rcirc-send-string :around 'rcirc-send-string-advice)

(defun rcirc-send-string-advice (orig-fun &rest args)
  (let ((process (car args))
        (text (cadr args)))
   (when (string-prefix-p "USER " text)
     (apply orig-fun (list process "CAP REQ znc.in/playback")))
   (apply orig-fun args)))

Next, we need to register a function to respond to the ACK. rcirc dispatches commands to "(concat rcirc-handler- cmd)", so all we need to do is define rcirc-handler-CAP.

(defun rcirc-handler-CAP (process sender args text)
  (rcirc-check-auth-status process sender args text)
  (let ((response (cadr args))
        (capab (caddr args)))
    (when (and *rcirc-last-message-time-initial*
               (string= response "ACK")
               (string= capab "znc.in/playback"))
      (rcirc-send-privmsg process "*playback" (format "Play * %d" (+ *rcirc-last-message-time-initial* 1))))))

I set rcirc-last-message-time-initial in a before advice hook:

(advice-add 'rcirc-connect :before 'rcirc-pre-connect-advice)

(defun rcirc-pre-connect-advice (&rest args)
  (when (file-exists-p *rcirc-last-message-time-file*)
    (setq *rcirc-last-message-time-initial*
          (with-temp-buffer (insert-file-contents *rcirc-last-message-time-file*)
                            (read (current-buffer))))))

Finally, we need to update the last message time. Rcirc doesn't understand message tags (the key/value pairs after "@"), so you have to modify the function which processes lines from the server: "rcirc-process-server-response-1".

(advice-add 'rcirc-process-server-response-1 :around 'rcirc-process-server-response-advice)

(defun rcirc-process-server-response-advice (orig-fun &rest args)
  (let ((text (cadr args)))
    (if (string-match "^\\(@\\([^ ]+\\) \\)?\\(\\(:[^ ]+ \\)?[^ ]+ .+\\)$" text)
        (let ((tags (match-string 2 text))
              (rest (match-string 3 text)))
          (when tags
            (rcirc-handle-message-tags (rcirc-parse-tags tags)))
          (apply orig-fun (list (car args) rest)))
      (apply orig-fun args))))

(defun rcirc-handle-message-tags (tags)
  (let* ((time (cdr (assoc "time" tags)))
         (timestamp (floor (float-time (date-to-time time)))))
    (setq *rcirc-last-message-time* timestamp)
    (with-temp-file *rcirc-last-message-time-file*
      (insert (with-output-to-string (princ *rcirc-last-message-time*))))))

(defun rcirc-parse-tags (tags)
  "Parse TAGS message prefix."
  (mapcar (lambda (tag)
            (let ((p (split-string tag "=")))
              `(,(car p) . ,(cadr p))))
          (split-string tags ";")))

Full source