aboutsummaryrefslogtreecommitdiff
path: root/modules/slack-config.el
blob: 0902ef35cd0cf5d57a8367da9fb515cef300fb57 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
;;; slack-config.el --- Slack Configuration -*- lexical-binding: t; coding: utf-8; -*-
;; author Craig Jennings <c@cjennings.net>

;;; Commentary:
;;
;; Layer: 4 (Optional).
;; Category: O/D/P.
;; Load shape: eager.
;; Eager reason: none; optional Slack client, a command-loaded deferral
;;   candidate. Auth and which-key labels should be after-load.
;; Top-level side effects: package configuration via use-package.
;; Runtime requires: system-lib, cl-lib.
;; Direct test load: yes.
;;
;; Slack client using emacs-slack (https://github.com/emacs-slack/emacs-slack).
;;
;; Authentication:
;;   Credentials are stored in authinfo.gpg (never plaintext).
;;   Add an entry like:
;;     machine deepsatworkspace.slack.com login TOKEN password xoxc-YOUR-TOKEN
;;     machine deepsatworkspace.slack.com login COOKIE password xoxd-YOUR-COOKIE
;;
;;   To get these values, run M-x slack-refresh-token and follow the
;;   instructions to extract them from your browser's developer tools.
;;
;; Keybindings (C-; S prefix):
;;   C-; S s  — connect to Slack
;;   C-; S c  — select unread rooms
;;   C-; S C  — select any channel/room
;;   C-; S d  — direct message a person
;;   C-; S w  — compose message in separate buffer
;;   C-; S r  — reply / show thread
;;   C-; S e  — insert emoji
;;   C-; S !  — add reaction to message
;;   C-; S @  — embed @mention
;;   C-; S #  — embed #channel
;;   C-; S q  — mark channel read and bury buffer
;;   C-; S Q  — close all Slack buffers and windows
;;   C-; S S  — disconnect
;;
;; Compose buffer:
;;   C-<return> — send message

;;; Code:

(require 'system-lib)  ;; provides cj/auth-source-secret-value
(require 'cl-lib)

(defvar slack-current-buffer)
(defvar slack-message-compose-buffer-mode-map)
(defvar slack-message-custom-notifier)
(defvar slack-teams)

(declare-function slack-buffer-add-reaction-to-message "slack-buffer")
(declare-function slack-buffer-latest-ts "slack-buffer")
(declare-function slack-buffer-team "slack-buffer")
(declare-function slack-buffer-update-mark-request "slack-buffer")
(declare-function slack-get-ts "slack-util")
(declare-function slack-im-p "slack-im")
(declare-function slack-message-body "slack-message")
(declare-function slack-message-embed-channel "slack-message-buffer")
(declare-function slack-message-embed-mention "slack-message-buffer")
(declare-function slack-message-mentioned-p "slack-message")
(declare-function slack-message-minep "slack-message")
(declare-function slack-message-reaction-input "slack-message-reaction")
(declare-function slack-message-send-from-buffer "slack-message-sender")
(declare-function slack-message-write-another-buffer "slack-message-buffer")
(declare-function slack-reaction-echo-description "slack-buffer")
(declare-function slack-room-display-name "slack-room")
(declare-function slack-ws-close "slack")

(defvar cj/slack-workspace "deepsatworkspace.slack.com"
  "Slack workspace domain for auth-source lookup.")

(defun cj/slack--get-credential (login-key)
  "Look up LOGIN-KEY credential for the Slack workspace from auth-source."
  (cj/auth-source-secret-value cj/slack-workspace login-key))

(defun cj/slack-start ()
  "Connect to Slack, registering the team if needed."
  (interactive)
  (require 'slack)
  (let ((token (cj/slack--get-credential "TOKEN"))
        (cookie (cj/slack--get-credential "COOKIE")))
    (unless token
      (user-error "No Slack token found in authinfo for %s (login: TOKEN)" cj/slack-workspace))
    (unless cookie
      (user-error "No Slack cookie found in authinfo for %s (login: COOKIE)" cj/slack-workspace))
    (unless (and (boundp 'slack-teams) slack-teams)
      (slack-register-team
       :name "DeepSat"
       :token token
       :cookie cookie
       :full-and-display-names t
       :default t))
    (slack-start)))

(defun cj/slack-stop ()
  "Disconnect from Slack."
  (interactive)
  (require 'slack)
  (slack-ws-close)
  (message "Slack disconnected."))

(defun cj/slack--display-buffer (buffer)
  "Display Slack BUFFER in another window, never the selected one.
With a split, reuse one of the other windows rather than taking over the
window point is in or always spawning a fresh split; with a lone window,
split.  Wired as `slack-buffer-function' so opening a room or thread lands
beside the current work instead of clobbering it.  The default
`switch-to-buffer-other-window' picks a least-recently-used window with three
or more panes; this pins the choice to any non-selected window."
  (pop-to-buffer buffer
                 '((display-buffer-reuse-window
                    display-buffer-use-some-window
                    display-buffer-pop-up-window)
                   (inhibit-same-window . t))))

(use-package slack
  :defer t
  :commands (slack-start slack-select-rooms slack-select-unread-rooms
             slack-im-select slack-thread-show-or-create
             slack-insert-emoji slack-register-team)
  :custom
  ;; Disabled: emojify-mode in lui buffers causes (wrong-type-argument listp)
  ;; errors on emoji characters during lui-scroll-post-command's recenter call.
  ;; Native emoji rendering via Noto Color Emoji fontset works fine without it.
  ;; Re-enable if emojify/circe fix the interaction. (2026-03-16)
  (slack-buffer-emojify nil)
  (slack-prefer-current-team t)
  ;; Open rooms/threads in another window, never the selected one (see
  ;; cj/slack--display-buffer) so Slack lands beside the current work in a split.
  (slack-buffer-function #'cj/slack--display-buffer)
  :config
  (setq slack-message-custom-notifier #'cj/slack-notify))

;; ------------------------------ Reactions ------------------------------------

(defvar cj/slack-common-reactions
  '(("thumbs up" . "+1")
    ("thumbsup" . "thumbsup")
    ("thumbs down" . "-1")
    ("pray" . "pray")
    ("raised hands" . "raised_hands")
    ("eyes" . "eyes")
    ("white check mark" . "white_check_mark")
    ("heavy check mark" . "heavy_check_mark")
    ("plus one" . "+1")
    ("heart" . "heart")
    ("heart eyes" . "heart_eyes")
    ("joy" . "joy")
    ("laughing" . "laughing")
    ("smile" . "smile")
    ("thinking face" . "thinking_face")
    ("rocket" . "rocket")
    ("fire" . "fire")
    ("party" . "tada")
    ("clap" . "clap")
    ("ok hand" . "ok_hand"))
  "Curated common Slack reaction labels mapped to Slack emoji names.")

(defun cj/slack--safe-reaction-echo-description (orig-fun &rest args)
  "Call ORIG-FUN safely from `post-command-hook'.
If emacs-slack sees a malformed reaction text property, remove the local hook
so the Slack buffer stays usable."
  (condition-case err
      (apply orig-fun args)
    (error
     (remove-hook 'post-command-hook #'slack-reaction-echo-description t)
     (message "Slack reaction hover disabled in this buffer: %s"
              (error-message-string err)))))

(defun cj/slack--reaction-candidates ()
  "Return display candidates for `cj/slack-common-reactions'."
  (append
   (mapcar (lambda (entry)
             (let ((label (car entry))
                   (name (cdr entry)))
               (cons (format "%-18s :%s:" label name) name)))
           cj/slack-common-reactions)
   '(("Other..." . :other))))

(defun cj/slack-select-reaction (team)
  "Select a Slack reaction for TEAM, preferring a short common list."
  (let* ((candidates (cj/slack--reaction-candidates))
         (choice (completing-read "Reaction: " candidates nil t))
         (reaction (cdr (assoc choice candidates))))
    (if (eq reaction :other)
        (slack-message-reaction-input team)
      reaction)))

(defun cj/slack-message-add-reaction ()
  "Add a reaction to the current Slack message using a curated shortlist.
Errors if called outside a Slack message buffer."
  (interactive)
  (let ((buf (or slack-current-buffer
                 (user-error "Not in a Slack buffer"))))
    (when-let* ((team (slack-buffer-team buf))
                (reaction (cj/slack-select-reaction team)))
      (slack-buffer-add-reaction-to-message buf reaction (slack-get-ts)))))

(with-eval-after-load 'slack-buffer
  (advice-add 'slack-reaction-echo-description
              :around #'cj/slack--safe-reaction-echo-description))

;; ----------------------------- Notifications ---------------------------------

(defun cj/slack-notify (message room team)
  "Send desktop notification for DMs and @mentions only.
MESSAGE is the incoming slack message, ROOM is the channel/DM,
TEAM is the slack team object.
Errors are logged to *Messages* since the websocket library silently
swallows exceptions via `websocket-try-callback'."
  (condition-case err
      (when (and (not (slack-message-minep message team))
                 (or (slack-im-p room)
                     (slack-message-mentioned-p message team)))
        (let ((title (format "Slack: %s" (slack-room-display-name room team)))
              (body (or (slack-message-body message team) "")))
          (start-process "slack-notify" nil
                         "notify" "info" title body)))
    (error (message "cj/slack-notify error: %S" err))))

(defun cj/slack-test-notify ()
  "Send a test desktop notification to verify the notify pipeline works."
  (interactive)
  (condition-case err
      (start-process "slack-notify-test" nil
                     "notify" "info" "Slack: Test" "Notification pipeline works")
    (error (message "cj/slack-test-notify error: %S" err))))

(defun cj/slack-mark-read-and-bury ()
  "Mark the current Slack channel as read and bury the buffer."
  (interactive)
  (when (and (boundp 'slack-current-buffer) slack-current-buffer)
    (let ((ts (slack-buffer-latest-ts slack-current-buffer)))
      (when ts
        (slack-buffer-update-mark-request slack-current-buffer ts))))
  (bury-buffer))

(defun cj/slack-close-all-buffers ()
  "Kill all Slack buffers and delete their windows."
  (interactive)
  (let ((count 0))
    (dolist (buf (buffer-list))
      (when (buffer-local-value 'slack-current-buffer buf)
        (let ((win (get-buffer-window buf t)))
          (when (and win (not (window-dedicated-p win)))
            (delete-window win)))
        (kill-buffer buf)
        (cl-incf count)))
    (message "Closed %d Slack buffer%s" count (if (= count 1) "" "s"))))

;; ------------------------------ Keybindings ----------------------------------

(defvar cj/slack-keymap (make-sparse-keymap)
  "Keymap for Slack commands under C-; S.")

(global-set-key (kbd "C-; S") cj/slack-keymap)

(define-key cj/slack-keymap (kbd "s") #'cj/slack-start)
(define-key cj/slack-keymap (kbd "c") #'slack-select-unread-rooms)
(define-key cj/slack-keymap (kbd "C") #'slack-select-rooms)
(define-key cj/slack-keymap (kbd "d") #'slack-im-select)
(define-key cj/slack-keymap (kbd "w") #'slack-message-write-another-buffer)
(define-key cj/slack-keymap (kbd "r") #'slack-thread-show-or-create)
(define-key cj/slack-keymap (kbd "e") #'slack-insert-emoji)
(define-key cj/slack-keymap (kbd "!") #'cj/slack-message-add-reaction)
(define-key cj/slack-keymap (kbd "@") #'slack-message-embed-mention)
(define-key cj/slack-keymap (kbd "#") #'slack-message-embed-channel)
(define-key cj/slack-keymap (kbd "q") #'cj/slack-mark-read-and-bury)
(define-key cj/slack-keymap (kbd "Q") #'cj/slack-close-all-buffers)
(define-key cj/slack-keymap (kbd "S") #'cj/slack-stop)

;; Register which-key labels lazily so this module's require doesn't
;; depend on which-key being loaded.  Other config modules use the same
;; pattern.
(with-eval-after-load 'which-key
  (which-key-add-keymap-based-replacements cj/slack-keymap
    "" "slack menu"
    "s" "start slack"
    "c" "unread rooms"
    "C" "select channel"
    "d" "direct message"
    "w" "compose message"
    "r" "reply / thread"
    "e" "insert emoji"
    "!" "add reaction"
    "@" "embed @mention"
    "#" "embed #channel"
    "q" "mark read & bury"
    "Q" "close all slack"
    "S" "disconnect"))

;; Send from compose buffer with C-<return>
(with-eval-after-load 'slack-message-compose-buffer
  (define-key slack-message-compose-buffer-mode-map (kbd "C-<return>") #'slack-message-send-from-buffer))

(provide 'slack-config)
;;; slack-config.el ends here.