Vivienne likes using Emacs to manage its email. It prefers notmuch
,
especially since it lends itself incredibly well to totally flattening
several disconnected email accounts and aliases into one location, but
mu4e
is another common option for this. Email already is how
Vivienne receives channel live notifications for twitch.tv. Recently,
it learned that you can watch Twitch streams using mpv
, whether via
yt-dlp
or streamlink
(streamlink seems to be more reliable for
this purpose), and that Twitch chats are normal IRC channels. This
caused it a flash of inspiration: What if it could open Twitch streams
in MPV and, and their associated chat channels in ERC, directly from
those notification emails in Emacs?
Well, first it implemented a command which allows it to start an MPV process connected to a stream via Streamlink from Emacs. When called interactively, the following will prompt for a username to connect to the stream. Or, the username can be provided as an argument when called from Elisp. This is a good start.
(defun twitch-open-stream (username)
"Open a twitch stream in MPV."
(interactive "Musername: ")
(start-process username nil
"streamlink" "--player" "mpv"
(format "twitch.tv/%s" username)
"480p,480p60,720p,720p60"))
Next, it needed a command which would join the chat channel. There are
a couple of notable things about this. Firstly, to authenticate, you
provide your Twitch username as your nick, and an oauth token with the
user:read:chat
and user:write:chat
scopes as your password,
according to this
page
of the Twitch developer docs. According to the docs, you may fetch a
token using the Twitch CLI like so:
twitch token --user-token --scopes "user:read:chat user:write:chat"
Great! Now we ought to store this authentication information somewhere. Emacs ships with a couple of APIs for managing secrets, perhaps most commonly used is auth-source, but Vivienne prefers to use the secrets API so that it can store this information on its system keyring which is unlocked by the session manager when it logs in.
To add an entry to your keyring, go ahead and run M-x ielm
so that
you may make a call to secrets-create-item
:
(secrets-create-item "Default keyring"
"twitch"
"oauth:your-oauth-token-here"
:user "your-twitch-username-here")
With this in mind, Vivienne wrote a function which would join the
Twitch IRC server, simply calling erc-tls
:
(defun twitch-join-network ()
(erc-tls :server "irc.chat.twitch.tv"
:port 6697
:id "Twitch"
:password
(secrets-get-secret "Default keyring" "twitch")
:nick
(secrets-get-attribute "Default keyring" "twitch" :user)))
This is where the next little quirk comes up. By default, the Twitch IRC server does not expose any commands, nor notifies the client of channel membership. Instead, upon connecting to the server, you are expected to request those capabilities by sending the following command:
CAP REQ :twitch.tv/membership twitch.tv/commands
Which, may be sent from elisp as
(erc-cmd-QUOTE CAP REQ :twitch.tv/membership twitch.tv/commands)
With this, Vivienne wrote a function which implements the following
logic: If the Twitch server is already connected, then use
erc-cmd-JOIN
to join the requested channel. But if its not already
connected, then start a new connection. But the commands to request
capabilities and join the channel cannot be sent until the connection
has been established! So, add a hook which requests capabilities
and join the requested channel and removes itself to
erc-after-connect
:
(defun twitch-join-chat (username)
(interactive "Musername: ")
(let ((b (get-buffer "Twitch")))
(if (erc-server-process-alive b)
(with-current-buffer b
(erc-cmd-JOIN (format "#%s" username)))
(with-current-buffer (twitch-join-network)
(letrec ((f (lambda (&rest _)
(remove-hook 'erc-after-connect f t)
(erc-cmd-QUOTE "CAP REQ :twitch.tv/membership twitch.tv/commands")
(erc-cmd-JOIN (format "#%s" username)))))
(add-hook 'erc-after-connect f nil t))))))
Note, the last argument given to add-hook
is t
, meaning to add
that hook buffer-locally.
Finally, Vivienne implemented a url handler to add to
browse-url-handlers
, so that when clicking Twitch links it'll call
twitch-open-stream
and twitch-join-chat
:
(defun open-twitch-url (url &rest _)
(string-match "https:\\/\\/www\\.twitch\\.tv\\/\\(.+\\)\\/?" url)
(twitch-open-stream (match-string 1 url))
(twitch-join-chat (match-string 1 url)))
(setq browse-url-handlers
`(("https://www.twitch.tv/" . open-twitch-url)))
Notmuch
At the beginning of the article, Vivienne wrote that it uses notmuch
to manage it email. This opens a great opportunity. Vivienne wants to
receive all Twitch notifications, so that it doesn't miss a
stream. However, most of the time, these notifications won't be acted
on, because Vivienne doesn't actually want to watch every
stream. It runs notmuch new
on a cron job which runs every fifteen
minutes. Taking advantage of this, in the Notmuch post-new
script, it added a line to remove the inbox
and unread
tags
from notifications from Twitch that are more than three hours old:
notmuch tag -inbox -unread -- from:*@twitch.tv and not date:3_hrs..now