├── .gitignore ├── README.org └── accord.el /.gitignore: -------------------------------------------------------------------------------- 1 | *.elc 2 | \#*# 3 | .#* 4 | *~ 5 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | * Accord: Emacs to Discord interface 2 | 3 | Accord allows you to control Discord from within Emacs via xdotool. 4 | 5 | ** EXPERIMENTAL 6 | I make no guarantees on the stability of this package. 7 | 8 | ** Demo 9 | [[https://www.youtube.com/watch?v=0qPpajd9ZjA][https://img.youtube.com/vi/0qPpajd9ZjA/0.jpg]] 10 | -------------------------------------------------------------------------------- /accord.el: -------------------------------------------------------------------------------- 1 | ;;; accord.el --- Xdotool Driven Discord Interface -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2019-2024 Nicholas Vollmer 4 | 5 | ;; Author: Nicholas Vollmer 6 | ;; URL: https://github.com/progfolio/accord 7 | ;; Created: October 09, 2020 8 | ;; Keywords: convenience 9 | ;; Package-Requires: ((emacs "27.1") (markdown-mode "0.0.0")) 10 | ;; Version: 0.0.0 11 | 12 | ;; This file is not part of GNU Emacs. 13 | 14 | ;; This program is free software: you can redistribute it and/or modify 15 | ;; it under the terms of the GNU General Public License as published by 16 | ;; the Free Software Foundation, either version 3 of the License, or 17 | ;; (at your option) any later version. 18 | 19 | ;; This program is distributed in the hope that it will be useful, 20 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 21 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 22 | ;; GNU General Public License for more details. 23 | 24 | ;; You should have received a copy of the GNU General Public License 25 | ;; along with this program. If not, see . 26 | 27 | ;;; Commentary: 28 | ;; This package provides an xdotool driven interface to the Discord desktop application. 29 | 30 | ;;; Code: 31 | (unless (executable-find "xdotool") 32 | (user-error "Accord cannot run without xdotool. Is it installed and on your PATH?")) 33 | 34 | (eval-when-compile (require 'subr-x)) 35 | (require 'markdown-mode) 36 | 37 | ;;; Custom Options 38 | 39 | (defgroup accord nil 40 | "Xdotool Driven Discord Interface." 41 | :group 'convenience 42 | :prefix "accord-") 43 | 44 | (defcustom accord-window-regexp "(#|@)?.*Discord" 45 | "Regexp to identify Discord window." 46 | :type 'string) 47 | 48 | (defcustom accord-buffer-name "*accord*" 49 | "Name of the accord buffer." 50 | :type 'string) 51 | 52 | (defcustom accord-process-buffer-name "*accord-process*" 53 | "Name of the accord process buffer." 54 | :type 'string) 55 | 56 | (defcustom accord-key-delay-time 100 57 | "Number of milliseconds to delay between each xdotool key press." 58 | :type 'number) 59 | 60 | (defcustom accord-character-limit 2000 61 | "Character limit for Discord messages before they are converted to file." 62 | :type 'number) 63 | 64 | ;;; Variables 65 | 66 | (defvar accord--edit-in-progress nil 67 | "Internal variable for tracking edit progress.") 68 | 69 | ;;; Functions 70 | (defun accord--current-window () 71 | "Return ID for currently focused window." 72 | (string-trim (shell-command-to-string "xdotool getwindowfocus"))) 73 | 74 | (defun accord--window-by-name (&optional regexp visible) 75 | "Return window ID for window name matching REGEXP. 76 | If VISIBLE is non-nil only search for visible windows. 77 | TARGET used to refocus original window." 78 | ;;This is more complicated that I would like it to be, but so is life. 79 | ;;Discord returns several window ID's when searching by name. 80 | ;;In order to get the one we're interested in sending keys to, we need to 81 | ;;first focus the window and then redo the search with "--onlyvisible". 82 | (let* ((current (accord--current-window)) 83 | (command (concat "xdotool search --name " 84 | (when visible "--onlyvisible ") 85 | "\"" (or regexp accord-window-regexp) "\"")) 86 | (ids (or (split-string (shell-command-to-string command) "\n" 'omit-nulls) 87 | (user-error "Target window not found. Is Discord open?")))) 88 | (call-process "xdotool" nil (get-buffer-create accord-process-buffer-name) 89 | t "windowactivate" current) 90 | ids)) 91 | 92 | (defun accord--erase-buffer () 93 | "Erase `accord-buffer-name'." 94 | (with-current-buffer (get-buffer-create accord-buffer-name) (erase-buffer))) 95 | 96 | (defun accord-send-commands (&rest commands) 97 | "Send COMMANDS to target window." 98 | (let ((current (accord--current-window)) 99 | (ids (accord--window-by-name))) 100 | (dolist (id ids) 101 | (call-process "xdotool" nil nil nil "windowactivate" id)) 102 | (apply #'call-process 103 | `("xdotool" nil ,(get-buffer-create accord-process-buffer-name) t 104 | ,@(mapcar (lambda (el) (format "%s" el)) (flatten-list commands)) 105 | "windowactivate" ,current)))) 106 | 107 | (defun accord--reset-header-line () 108 | "Reset the header-line." 109 | (with-current-buffer (get-buffer-create accord-buffer-name) 110 | (setq header-line-format 111 | (substitute-command-keys 112 | (concat "\\Accord buffer. " 113 | "Send: `\\[accord-send-message]' " 114 | "Edit: `\\[accord-edit-message]' " 115 | "Delete: `\\[accord-delete-message]'"))))) 116 | 117 | (defun accord--clear-input () 118 | "Return command string to clear input area." 119 | `("key" "--delay" ,(/ accord-key-delay-time 5) "ctrl+a" 120 | "keyup" "ctrl+a" 121 | ;; Using two BackSpaces here because of the way Discord handles quoted text. 122 | ;; It requires a second delete to clear the inserted quote bar... 123 | "key" "--delay" ,accord-key-delay-time "BackSpace" 124 | "key" ,accord-key-delay-time "BackSpace")) 125 | 126 | (defun accord--confirm () 127 | "Return command string to confirm text input." 128 | `("key" "--delay" ,accord-key-delay-time "Return")) 129 | 130 | (defun accord--copy-input () 131 | "Return command string to paste clipboard." 132 | ;;keyup necessary here? 133 | `("key" "--delay" ,accord-key-delay-time "ctrl+a" "ctrl+c")) 134 | 135 | (defun accord--open-last () 136 | "Return command string to open last message." 137 | ;;keyup necessary here? 138 | `(,@(accord--clear-input) 139 | "key" "--delay" ,accord-key-delay-time "Up")) 140 | 141 | (defun accord--paste () 142 | "Return command string to paste clipboard." 143 | ;;keyup necessary here? 144 | `("key" "--delay" ,(/ accord-key-delay-time 4) "ctrl+v" "keyup" "--delay" 145 | ,(/ accord-key-delay-time 4) "ctrl+v")) 146 | 147 | (defun accord--last-message () 148 | "Return last sent message." 149 | ;; Clear in case we have something already stored in the clipboard 150 | (gui-set-selection 'CLIPBOARD nil) 151 | (accord-send-commands 152 | (accord--open-last) 153 | (accord--copy-input) 154 | "Escape") 155 | (when-let ((selection (gui-get-selection 'CLIPBOARD))) 156 | (substring-no-properties selection))) 157 | 158 | ;;; Commands 159 | 160 | ;;;###autoload 161 | (defun accord-delete-message (&optional noconfirm) 162 | "Delete last posted message. 163 | If NOCONFIRM is non-nil, do not prompt user for confirmation." 164 | (interactive "P") 165 | (if accord--edit-in-progress 166 | (accord--edit-abort) 167 | (let (last) 168 | (unless noconfirm 169 | (setq last (or (accord--last-message) (user-error "Unable to delete last message")))) 170 | (when (or noconfirm (yes-or-no-p (format "Delete message: %S?" last))) 171 | (accord-send-commands 172 | (accord--open-last) 173 | (accord--clear-input) 174 | ;; Discord doesn't let you delete without confirming and the pop-up takes 175 | ;; some time to appear... 176 | (let ((accord-key-delay-time (* accord-key-delay-time 3))) (accord--confirm)) 177 | (accord--confirm)))))) 178 | 179 | (defun accord--edit-abort () 180 | "Abort the current edit." 181 | (setq accord--edit-in-progress nil) 182 | (accord--erase-buffer) 183 | (accord--reset-header-line)) 184 | 185 | ;;;###autoload 186 | (defun accord-edit-message () 187 | "Edit last message." 188 | (interactive) 189 | (unless (derived-mode-p 'accord-mode) (accord)) 190 | (when accord--edit-in-progress 191 | (user-error "Edit already in progress. Send or Abort")) 192 | (insert (accord--last-message)) 193 | (goto-char (point-min)) 194 | (setq header-line-format 195 | (substitute-command-keys 196 | (concat "\\Accord buffer. " 197 | "Send: `\\[accord-send-message]' " 198 | "Abort Edit: `\\[accord-delete-message]'")) 199 | accord--edit-in-progress t)) 200 | 201 | (defun accord--chunk-message (message &optional limit) 202 | "Send MESSAGE in chunks under LIMIT. 203 | If LIMIT is nil, `accord-character-limit' is used." 204 | (let* ((chars (cdr (split-string message ""))) 205 | (charcount 0) 206 | (limit (or limit accord-character-limit)) 207 | chunk chunks char) 208 | (while (setq char (pop chars)) 209 | (if (< charcount limit) 210 | (progn 211 | (push char chunk) 212 | (setq charcount (1+ charcount))) 213 | ;; don't break words when chunking 214 | (while (not (string-match "[[:space:]]" char)) 215 | (push char chars) 216 | (setq char (pop chunk))) 217 | (push char chars) 218 | (push chunk chunks) 219 | (setq chunk '() 220 | charcount 0))) 221 | ;;push last chunk 222 | (push chunk chunks) 223 | (nreverse (mapcar (lambda (chunk) (string-join (nreverse chunk) "")) chunks)))) 224 | 225 | 226 | ;;;###autoload 227 | (defun accord-send-message (&optional nochunk message) 228 | "Send MESSAGE to Discord. 229 | If MESSAGE is non-nil region is sent if active, otherwise entire buffer is sent. 230 | If NOCHUNK is non-nil do not send messages over `accord-character-limit' 231 | as seperate messages. 232 | In this case, Discord will upload the message as a text file." 233 | (interactive "P") 234 | (let* ((previous (and accord--edit-in-progress 235 | (not (string= (buffer-name) accord-buffer-name)))) 236 | (keep (when previous 237 | (not (yes-or-no-p (format "Abort edit in progress in %S buffer?" 238 | accord-buffer-name)))))) 239 | (when previous 240 | (if keep 241 | (accord) 242 | (accord--edit-abort) 243 | (setq keep nil))) 244 | (unless keep 245 | (let* ((message (or message 246 | (string-trim (apply #'buffer-substring-no-properties 247 | (if (region-active-p) 248 | (list (region-beginning) (region-end)) 249 | (list (point-min) (point-max))))))) 250 | (messages (if nochunk (list message) (accord--chunk-message message))) 251 | (messages-count (length messages))) 252 | (dolist (message messages) 253 | (when (string-empty-p message) (user-error "Can't send empty message")) 254 | (gui-set-selection 'CLIPBOARD message) 255 | (accord-send-commands 256 | (when accord--edit-in-progress (accord--open-last)) 257 | (accord--clear-input) 258 | (accord--paste) 259 | (accord--confirm)) 260 | ;;@Hack: still subject to race conditions 261 | ;;if one process isn't finished before next starts 262 | (when (> messages-count 1) (sleep-for (/ accord-key-delay-time 1000.0)))) 263 | (undo-boundary) 264 | (accord--erase-buffer) 265 | (when accord--edit-in-progress 266 | (accord--reset-header-line) 267 | (setq accord--edit-in-progress nil)))))) 268 | 269 | ;;;###autoload 270 | (defun accord-channel-last () 271 | "Select last selected channel." 272 | (interactive) 273 | (accord-send-commands "key" "--delay" (/ accord-key-delay-time 4) "ctrl+k" "Return")) 274 | 275 | ;;;###autoload 276 | (defun accord-channel-goto-unread () 277 | "Goto unread first unread message in channel." 278 | (interactive) 279 | (accord-send-commands "key" "--delay" (/ accord-key-delay-time 4) "Shift+Page_Up")) 280 | 281 | ;;;###autoload 282 | (defun accord-channel-mark-read () 283 | "Mark channel as read." 284 | (interactive) 285 | (accord-send-commands "key" "--delay" (/ accord-key-delay-time 4) "Escape")) 286 | 287 | (defun accord--select (entity direction) 288 | "Choose previous ENTITY in DIRECTION. 289 | ENTITY may be either `server` or `channel`." 290 | (interactive) 291 | (accord-send-commands "key" "--delay" accord-key-delay-time 292 | (concat (when (eq entity 'server) "ctrl+") "alt+" direction))) 293 | 294 | ;;@TODO: should probably take a numeric arg to repeat 295 | ;;;###autoload 296 | (defun accord-channel-next () 297 | "Select next channel in server." 298 | (interactive) 299 | (accord--select 'channel "Down")) 300 | 301 | ;;;###autoload 302 | (defun accord-channel-prev () 303 | "Select previous channel in server." 304 | (interactive) 305 | (accord--select 'channel "Up")) 306 | 307 | (defun accord-channel--scroll (direction) 308 | "Scroll channel in DIRECTION." 309 | (interactive) 310 | (accord-send-commands "key" "--delay" (/ accord-key-delay-time 4) direction)) 311 | 312 | ;;;###autoload 313 | (defun accord-channel-scroll-down (&optional bottom) 314 | "Scroll channel down. 315 | If BOTTOM is non-nil, return to bottom of channel." 316 | (interactive "P") 317 | (accord-channel--scroll (concat (when bottom "Shift+") "Page_Down"))) 318 | 319 | ;;;###autoload 320 | (defun accord-channel-scroll-up () 321 | "Select next channel in server." 322 | (interactive) 323 | (accord-channel--scroll "Page_Up")) 324 | 325 | ;;;###autoload 326 | (defun accord-channel-search (&optional query) 327 | "Select channel by QUERY." 328 | (interactive) 329 | (let ((search (or query (read-string "accord channel: ")))) 330 | (accord-send-commands 331 | "key" "--delay" "80" "ctrl+k" 332 | "key" "--delay" "20" 333 | (cdr (mapcar (lambda (char) (replace-regexp-in-string " " "space" char)) 334 | (split-string search ""))) 335 | (accord--confirm)))) 336 | 337 | ;;;###autoload 338 | (defun accord-server-prev () 339 | "Select previous channel in server." 340 | (interactive) 341 | (accord--select 'server "Up")) 342 | 343 | ;;;###autoload 344 | (defun accord-server-next () 345 | "Select next channel in server." 346 | (interactive) 347 | (accord--select 'server "Down")) 348 | 349 | ;;;###autoload 350 | (defun accord () 351 | "Toggle accord buffer." 352 | (interactive) 353 | (if (string= (buffer-name) accord-buffer-name) 354 | (delete-window) 355 | (let ((buffer (get-buffer-create accord-buffer-name))) 356 | (with-current-buffer buffer 357 | (unless (derived-mode-p 'accord-mode) (accord-mode))) 358 | (switch-to-buffer-other-window buffer)))) 359 | 360 | ;;; Mode definition 361 | (defvar accord-mode-map 362 | (let ((map (make-sparse-keymap))) 363 | (define-key map (kbd "C-c C-c") 'accord-send-message) 364 | (define-key map (kbd "C-c C-e") 'accord-edit-message) 365 | (define-key map (kbd "C-c C-k") 'accord-delete-message) 366 | map)) 367 | 368 | (define-derived-mode accord-mode markdown-mode "accord" 369 | "Send messages to Discord from Emacs." 370 | (accord--reset-header-line)) 371 | 372 | (provide 'accord) 373 | 374 | ;;; accord.el ends here 375 | --------------------------------------------------------------------------------