slk 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +41 -1
- data/lib/slk/commands/status.rb +142 -17
- data/lib/slk/formatters/json_status_formatter.rb +91 -0
- data/lib/slk/models/dnd_state.rb +81 -0
- data/lib/slk/models/status_snapshot.rb +30 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae44e63120d4264c9adeb4adb2ea9aba96c4fe9e6087c1134f9bd9d8480a843e
|
|
4
|
+
data.tar.gz: '087ea385b7cf62868737d1e583e60ec9fac5aee8bb3d49f711f5c979413db4e7'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71be8f27208e35f5dfe81c3504f38f40af7a7630d93802ba56316e07778aa631f7a42ffe1172b9b1c280735871c0667b5b6568a3bb0224642cdabf59523ac8c5
|
|
7
|
+
data.tar.gz: 9e4b991d52004df5100fe901e87c1b61f8c50a8fa501665f2cb0a5f58f31a37a6ffae6cbe5c287aac7ae2e99f679cea0889c80866f7980b7f495c7311a507c73
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-08-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`slk status` now answers the questions the status raises** — who can reach you, and whether the status is about to change on its own
|
|
13
|
+
- Presence and DND appear as suffixes on the status line: `:computer: Working [away] [dnd until 3:00pm]`. Only exceptional states are labelled, so the workspace that differs stands out rather than being buried under "active" on every line
|
|
14
|
+
- `[dnd until …]` covers a manual snooze and the configured DND hours alike — from the outside both mean messages do not notify — and reports the later end when both apply. `slk dnd` still breaks out which is which
|
|
15
|
+
- Anything scheduled to turn on later is listed under the status, soonest first and without IDs; `slk status scheduled` remains the view to paste an ID from
|
|
16
|
+
- Times carry a day when the end is not today, so an overnight window does not read as a time that has already passed
|
|
17
|
+
- **`slk status --json`** (and `slk status scheduled --json`) for scripts and statuslines
|
|
18
|
+
- Always an array, one entry per workspace, even for a single workspace: a document whose shape changes with `-w` cannot be parsed by a script that did not pass it
|
|
19
|
+
- `null` means "not checked" — skipped by a flag, or a lookup that failed — as distinct from checked-and-empty. A statusline that read a failed DND lookup as "DND off" would report the opposite of the truth
|
|
20
|
+
- Every timestamp appears twice: Slack's own epoch under Slack's own field name, and an ISO 8601 string beside it
|
|
21
|
+
- **`--brief` and `--no-scheduled`** for the get view. It now costs up to four calls per workspace; `--no-scheduled` drops the internal `users.customStatus.list` lookup and `--brief` drops all three extras. `--quiet` skips them too, since their output would be discarded (`--json` still gathers them: it prints regardless)
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- A failed presence, DND or schedule lookup during `slk status` warns and omits that part instead of failing the command — the status itself is what was asked for. A rate limit stops the remaining extra lookups altogether rather than spending calls the status reads need
|
|
26
|
+
|
|
8
27
|
## [0.7.0] - 2026-08-03
|
|
9
28
|
|
|
10
29
|
### Added
|
|
@@ -210,6 +229,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
|
|
|
210
229
|
- Pure Ruby stdlib - no gem dependencies
|
|
211
230
|
- Ruby 3.2+ with modern features (Data.define, pattern matching)
|
|
212
231
|
|
|
232
|
+
[0.8.0]: https://github.com/ericboehs/slk/releases/tag/v0.8.0
|
|
213
233
|
[0.7.0]: https://github.com/ericboehs/slk/releases/tag/v0.7.0
|
|
214
234
|
[0.6.0]: https://github.com/ericboehs/slk/releases/tag/v0.6.0
|
|
215
235
|
[0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
|
data/README.md
CHANGED
|
@@ -46,12 +46,52 @@ You'll need a Slack token. Get one from:
|
|
|
46
46
|
### Status
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
slk status #
|
|
49
|
+
slk status # Status, presence, DND and what's queued next
|
|
50
|
+
slk status --brief # Status text only (one call per workspace)
|
|
51
|
+
slk status --json # Machine-readable, for scripts and statuslines
|
|
50
52
|
slk status "Working from home" :house: # Set status with emoji
|
|
51
53
|
slk status "In a meeting" :calendar: 1h # Set status for 1 hour
|
|
52
54
|
slk status clear # Clear status
|
|
53
55
|
```
|
|
54
56
|
|
|
57
|
+
Getting the status also answers the two questions it usually raises — can anyone
|
|
58
|
+
reach me, and is this about to change on its own:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
$ slk status
|
|
62
|
+
work
|
|
63
|
+
:computer: Working [away] [dnd until 3:00pm]
|
|
64
|
+
Scheduled:
|
|
65
|
+
:paw_prints: Vet Appt (Mon Aug 3 1:30pm -> 3:30pm)
|
|
66
|
+
side-project
|
|
67
|
+
(no status set)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Only the exceptional states are labelled: an active, notifiable workspace says
|
|
71
|
+
nothing, so the one that differs stands out. `[dnd until ...]` covers both a
|
|
72
|
+
manual snooze and the configured DND hours — from the outside they are the same
|
|
73
|
+
thing — and `slk dnd` breaks out which.
|
|
74
|
+
|
|
75
|
+
Each of those is a separate call, so `slk status` costs up to four per
|
|
76
|
+
workspace. `--no-scheduled` drops the schedule lookup (the one internal
|
|
77
|
+
endpoint), `--brief` drops all three, and a lookup that fails warns and leaves
|
|
78
|
+
that part out rather than failing the command.
|
|
79
|
+
|
|
80
|
+
#### JSON output
|
|
81
|
+
|
|
82
|
+
`slk status --json` (and `slk status scheduled --json`) prints one entry per
|
|
83
|
+
workspace — always an array, even for a single workspace, so a script that did
|
|
84
|
+
not pass `-w` can still parse it. `null` means *not checked* (skipped by a flag,
|
|
85
|
+
or the lookup failed), which is not the same as checked-and-empty: a statusline
|
|
86
|
+
that read a failed DND lookup as "DND off" would say the opposite of the truth.
|
|
87
|
+
Every timestamp appears twice, as Slack's epoch and as ISO 8601:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# "away, quiet until 3:00pm"
|
|
91
|
+
slk status -w work --json --no-scheduled |
|
|
92
|
+
jq -r '.[0] | "\(.presence.presence)\(if .dnd.active then ", quiet until " + (.dnd.until | strflocaltime("%-I:%M%p")) else "" end)"'
|
|
93
|
+
```
|
|
94
|
+
|
|
55
95
|
Statuses can also be scheduled to turn on later (Slack allows up to 5 at a time):
|
|
56
96
|
|
|
57
97
|
```bash
|
data/lib/slk/commands/status.rb
CHANGED
|
@@ -38,24 +38,39 @@ module Slk
|
|
|
38
38
|
protected
|
|
39
39
|
|
|
40
40
|
def default_options
|
|
41
|
-
super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil
|
|
41
|
+
super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil,
|
|
42
|
+
scheduled: true, brief: false)
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
# Flags that only flip a boolean live here rather than in the case below,
|
|
46
|
+
# which is already at the branch count the linter allows.
|
|
47
|
+
TOGGLES = { '--with-dnd' => [:with_dnd, true], '--brief' => [:brief, true],
|
|
48
|
+
'--no-scheduled' => [:scheduled, false] }.freeze
|
|
49
|
+
|
|
44
50
|
def handle_option(arg, args, remaining)
|
|
51
|
+
return toggle(arg) if TOGGLES.key?(arg)
|
|
52
|
+
|
|
45
53
|
case arg
|
|
46
54
|
when '-p', '--presence' then @options[:presence] = option_value(arg, args)
|
|
47
55
|
when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
|
|
48
|
-
when '--with-dnd' then @options[:with_dnd] = true
|
|
49
56
|
when '--start' then @options[:start_at] = option_value(arg, args)
|
|
50
57
|
when '--end' then @options[:end_at] = option_value(arg, args)
|
|
51
58
|
else super
|
|
52
59
|
end
|
|
53
60
|
end
|
|
54
61
|
|
|
62
|
+
# True means "consumed", the contract Base#handle_option expects back.
|
|
63
|
+
def toggle(arg) # rubocop:disable Naming/PredicateMethod
|
|
64
|
+
key, value = TOGGLES[arg]
|
|
65
|
+
@options[key] = value
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
55
69
|
def help_text
|
|
56
70
|
help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
|
|
57
71
|
help.description('Get or set your Slack status.')
|
|
58
|
-
help.note('GET shows
|
|
72
|
+
help.note('GET shows status, presence, DND and anything queued to turn on later.')
|
|
73
|
+
help.note('GET covers all workspaces by default; SET applies to primary only.')
|
|
59
74
|
help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
|
|
60
75
|
help.note('Slack allows at most 5 scheduled statuses at a time.')
|
|
61
76
|
add_examples_section(help)
|
|
@@ -66,7 +81,9 @@ module Slk
|
|
|
66
81
|
|
|
67
82
|
def add_examples_section(help)
|
|
68
83
|
help.section('EXAMPLES') do |s|
|
|
69
|
-
s.example('slk status', 'Show status (all workspaces)')
|
|
84
|
+
s.example('slk status', 'Show status, presence, DND and schedule (all workspaces)')
|
|
85
|
+
s.example('slk status --brief', 'Status text only, no extra lookups')
|
|
86
|
+
s.example('slk status --json', 'Machine-readable; null means "not checked"')
|
|
70
87
|
s.example('slk status clear', 'Clear status')
|
|
71
88
|
s.example('slk status "Working" :laptop:', 'Set status with emoji')
|
|
72
89
|
s.example('slk status "Meeting" :calendar: 1h', 'Set status for 1 hour')
|
|
@@ -89,6 +106,7 @@ module Slk
|
|
|
89
106
|
def add_options_section(help)
|
|
90
107
|
help.section('OPTIONS') do |s|
|
|
91
108
|
add_general_options(s)
|
|
109
|
+
add_getting_options(s)
|
|
92
110
|
add_scheduling_options(s)
|
|
93
111
|
end
|
|
94
112
|
end
|
|
@@ -102,6 +120,14 @@ module Slk
|
|
|
102
120
|
section.option('-q, --quiet', 'Suppress output')
|
|
103
121
|
end
|
|
104
122
|
|
|
123
|
+
# These are ignored outside a plain `slk status` (and `scheduled`, for
|
|
124
|
+
# --json), so say so rather than leaving them looking universal.
|
|
125
|
+
def add_getting_options(section)
|
|
126
|
+
section.option('--brief', 'Getting only: skip the presence, DND and schedule lookups')
|
|
127
|
+
section.option('--no-scheduled', 'Getting only: skip the scheduled lookup, keep presence and DND')
|
|
128
|
+
section.option('--json', 'Getting only: JSON for scripts (also `slk status scheduled`)')
|
|
129
|
+
end
|
|
130
|
+
|
|
105
131
|
# These are ignored outside `schedule`, so say so rather than leaving
|
|
106
132
|
# --with-dnd looking like a sibling of -d/--dnd.
|
|
107
133
|
def add_scheduling_options(section)
|
|
@@ -115,36 +141,116 @@ module Slk
|
|
|
115
141
|
def get_status # rubocop:disable Naming/AccessorMethodName
|
|
116
142
|
# GET defaults to all workspaces unless -w specified
|
|
117
143
|
workspaces = target_workspaces_for_get
|
|
144
|
+
snapshots = workspaces.map { |workspace| snapshot_for(workspace) }
|
|
145
|
+
return render_snapshots_json(snapshots) if @options[:json]
|
|
118
146
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
147
|
+
snapshots.each { |snapshot| print_snapshot(snapshot, labelled: workspaces.size > 1) }
|
|
148
|
+
0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The status text is what was asked for. Presence, DND and the pending
|
|
152
|
+
# schedule are what it is usually being read *for* — whether anyone can
|
|
153
|
+
# reach you, and whether the status is about to change on its own — so
|
|
154
|
+
# they are gathered alongside it, one call each, none of them fatal.
|
|
155
|
+
def snapshot_for(workspace)
|
|
156
|
+
status = runner.users_api(workspace.name).get_status
|
|
157
|
+
return Models::StatusSnapshot.new(workspace: workspace, status: status) unless details?
|
|
158
|
+
|
|
159
|
+
Models::StatusSnapshot.new(workspace: workspace, status: status, **workspace_details(workspace))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def workspace_details(workspace)
|
|
163
|
+
{
|
|
164
|
+
presence: detail(workspace, 'presence') { runner.users_api(workspace.name).get_presence },
|
|
165
|
+
dnd: detail(workspace, 'DND') { Models::DndState.from_api(runner.dnd_api(workspace.name).info) },
|
|
166
|
+
scheduled: pending_scheduled(workspace)
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# --brief drops the extra calls; under --quiet their output is discarded,
|
|
171
|
+
# so they would buy nothing. --json prints even when quiet, and its
|
|
172
|
+
# consumers are the ones that want the detail most.
|
|
173
|
+
def details?
|
|
174
|
+
return false if @options[:brief]
|
|
175
|
+
|
|
176
|
+
@options[:json] || !@options[:quiet]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def pending_scheduled(workspace)
|
|
180
|
+
return nil unless @options[:scheduled]
|
|
181
|
+
|
|
182
|
+
detail(workspace, 'scheduled statuses') do
|
|
183
|
+
in_order(runner.custom_status_api(workspace.name).scheduled)
|
|
122
184
|
end
|
|
185
|
+
end
|
|
123
186
|
|
|
187
|
+
# nil, not a blank value: the caller records "not checked", which --json
|
|
188
|
+
# reports as null. Reporting a failed DND lookup as "DND off" would say
|
|
189
|
+
# the opposite of the truth to anything reading it.
|
|
190
|
+
def detail(workspace, label)
|
|
191
|
+
return nil if @details_unavailable
|
|
192
|
+
|
|
193
|
+
yield
|
|
194
|
+
rescue RateLimitError => e
|
|
195
|
+
# Being throttled while decorating the answer is a reason to stop
|
|
196
|
+
# decorating. These calls are optional; the status reads they would
|
|
197
|
+
# crowd out are not.
|
|
198
|
+
@details_unavailable = true
|
|
199
|
+
warn("Rate limited; skipping presence, DND and scheduled lookups: #{e.message}")
|
|
200
|
+
nil
|
|
201
|
+
rescue ApiError => e
|
|
202
|
+
warn("Could not read #{label} on #{workspace.name}: #{e.message}")
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render_snapshots_json(snapshots)
|
|
207
|
+
output_json(json_formatter.format(snapshots))
|
|
124
208
|
0
|
|
125
209
|
end
|
|
126
210
|
|
|
211
|
+
def json_formatter = Formatters::JsonStatusFormatter.new
|
|
212
|
+
|
|
213
|
+
def print_snapshot(snapshot, labelled:)
|
|
214
|
+
puts output.bold(snapshot.workspace_name) if labelled
|
|
215
|
+
print_status_line(snapshot)
|
|
216
|
+
print_upcoming(snapshot.scheduled)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A status that turns on this afternoon is the reason to leave the
|
|
220
|
+
# current one alone, so it is listed under it rather than a command away.
|
|
221
|
+
def print_upcoming(scheduled)
|
|
222
|
+
return if scheduled.nil? || scheduled.empty?
|
|
223
|
+
|
|
224
|
+
puts ' Scheduled:'
|
|
225
|
+
# No IDs here; `slk status scheduled` is the view you paste from.
|
|
226
|
+
scheduled.each { |status| puts " #{status}" }
|
|
227
|
+
end
|
|
228
|
+
|
|
127
229
|
def target_workspaces_for_get
|
|
128
230
|
@options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
|
|
129
231
|
end
|
|
130
232
|
|
|
131
|
-
def
|
|
132
|
-
|
|
233
|
+
def print_status_line(snapshot)
|
|
234
|
+
suffix = snapshot.labels.join(' ')
|
|
133
235
|
|
|
134
|
-
if status.empty?
|
|
135
|
-
|
|
236
|
+
if snapshot.status.empty?
|
|
237
|
+
# Away or on DND with no status is still worth saying: it is the
|
|
238
|
+
# difference between "nothing to report" and "unreachable".
|
|
239
|
+
puts join_suffix(' (no status set)', suffix)
|
|
136
240
|
else
|
|
137
|
-
display_status(workspace, status)
|
|
241
|
+
display_status(snapshot.workspace, snapshot.status, suffix)
|
|
138
242
|
end
|
|
139
243
|
end
|
|
140
244
|
|
|
141
|
-
def
|
|
245
|
+
def join_suffix(line, suffix) = suffix.empty? ? line : "#{line} #{suffix}"
|
|
246
|
+
|
|
247
|
+
def display_status(workspace, status, suffix = '')
|
|
142
248
|
emoji_path = workspace_emoji_path(workspace.name, status.emoji)
|
|
143
249
|
|
|
144
250
|
if emoji_path && inline_images_supported?
|
|
145
|
-
print_status_with_image(emoji_path, status)
|
|
251
|
+
print_status_with_image(emoji_path, status, suffix)
|
|
146
252
|
else
|
|
147
|
-
puts " #{status}"
|
|
253
|
+
puts join_suffix(" #{status}", suffix)
|
|
148
254
|
end
|
|
149
255
|
end
|
|
150
256
|
|
|
@@ -153,10 +259,11 @@ module Slk
|
|
|
153
259
|
find_workspace_emoji(workspace_name, emoji_name)
|
|
154
260
|
end
|
|
155
261
|
|
|
156
|
-
def print_status_with_image(emoji_path, status)
|
|
262
|
+
def print_status_with_image(emoji_path, status, suffix = '')
|
|
157
263
|
parts = []
|
|
158
264
|
parts << status.text unless status.text.empty?
|
|
159
265
|
parts << "(#{status.time_remaining})" if status.time_remaining
|
|
266
|
+
parts << suffix unless suffix.empty?
|
|
160
267
|
print_inline_image_with_text(emoji_path, " #{parts.join(' ')}")
|
|
161
268
|
end
|
|
162
269
|
|
|
@@ -347,6 +454,7 @@ module Slk
|
|
|
347
454
|
|
|
348
455
|
def list_scheduled
|
|
349
456
|
workspaces = target_workspaces_for_get
|
|
457
|
+
return list_scheduled_json(workspaces) if @options[:json]
|
|
350
458
|
|
|
351
459
|
each_workspace_reporting(workspaces) do |workspace|
|
|
352
460
|
puts output.bold(workspace.name) if workspaces.size > 1
|
|
@@ -354,12 +462,29 @@ module Slk
|
|
|
354
462
|
end
|
|
355
463
|
end
|
|
356
464
|
|
|
465
|
+
# Every workspace appears whether or not its lookup worked, so the array
|
|
466
|
+
# still lines up with the workspaces asked about; a failed one is null
|
|
467
|
+
# rather than an empty list, and its reason went to stderr.
|
|
468
|
+
def list_scheduled_json(workspaces)
|
|
469
|
+
pending = workspaces.to_h { |workspace| [workspace.name, nil] }
|
|
470
|
+
|
|
471
|
+
code = each_workspace_reporting(workspaces) do |workspace|
|
|
472
|
+
pending[workspace.name] = in_order(runner.custom_status_api(workspace.name).scheduled)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
output_json(json_formatter.format_scheduled(pending))
|
|
476
|
+
code
|
|
477
|
+
end
|
|
478
|
+
|
|
357
479
|
def print_scheduled(scheduled)
|
|
358
480
|
return puts ' (none scheduled)' if scheduled.empty?
|
|
359
481
|
|
|
360
|
-
scheduled.each { |status| puts " #{status.id} #{status}" }
|
|
482
|
+
in_order(scheduled).each { |status| puts " #{status.id} #{status}" }
|
|
361
483
|
end
|
|
362
484
|
|
|
485
|
+
# Soonest first: the next one to turn on is the one being read for.
|
|
486
|
+
def in_order(scheduled) = scheduled.sort_by(&:date_scheduled)
|
|
487
|
+
|
|
363
488
|
def unschedule_status(args)
|
|
364
489
|
id = args.first
|
|
365
490
|
return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Slk
|
|
6
|
+
module Formatters
|
|
7
|
+
# Renders `slk status` snapshots as JSON for scripts and statuslines.
|
|
8
|
+
#
|
|
9
|
+
# Two shape rules the consumers depend on:
|
|
10
|
+
#
|
|
11
|
+
# - Always an array, one entry per workspace, even for a single one. The
|
|
12
|
+
# workspace set changes with -w/--all, and a document whose shape
|
|
13
|
+
# changes with a flag cannot be parsed by a script that did not pass it.
|
|
14
|
+
# - null means "not checked" (skipped, or the lookup failed); an empty
|
|
15
|
+
# array or false means checked. A statusline that treats a failed DND
|
|
16
|
+
# lookup as "DND off" would quietly say the opposite of the truth.
|
|
17
|
+
#
|
|
18
|
+
# Timestamps appear twice: the raw Slack epoch under Slack's own field name
|
|
19
|
+
# and an ISO 8601 string beside it, so neither jq nor the shell has to do
|
|
20
|
+
# date arithmetic to print "until 3:00pm".
|
|
21
|
+
class JsonStatusFormatter
|
|
22
|
+
def format(snapshots)
|
|
23
|
+
snapshots.map { |snapshot| snapshot_hash(snapshot) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `slk status scheduled --json`: the same array, narrowed to the part
|
|
27
|
+
# that command is about, so a script can read either with one shape.
|
|
28
|
+
#
|
|
29
|
+
# @param by_workspace [Hash{String => Array<Models::ScheduledStatus>, nil}]
|
|
30
|
+
def format_scheduled(by_workspace)
|
|
31
|
+
by_workspace.map do |name, scheduled|
|
|
32
|
+
{ workspace: name, scheduled: scheduled&.map { |status| scheduled_hash(status) } }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def snapshot_hash(snapshot)
|
|
39
|
+
{
|
|
40
|
+
workspace: snapshot.workspace_name,
|
|
41
|
+
status: status_hash(snapshot.status),
|
|
42
|
+
presence: presence_hash(snapshot.presence),
|
|
43
|
+
dnd: dnd_hash(snapshot.dnd),
|
|
44
|
+
scheduled: snapshot.scheduled&.map { |status| scheduled_hash(status) }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def status_hash(status)
|
|
49
|
+
{
|
|
50
|
+
text: status.text,
|
|
51
|
+
emoji: status.emoji,
|
|
52
|
+
# Slack's own 0-for-never, kept as-is; `expires_at` is the readable
|
|
53
|
+
# form and is null rather than "1970-01-01" when there is no expiry.
|
|
54
|
+
expiration: status.expiration,
|
|
55
|
+
expires_at: iso8601(status.expiration_time),
|
|
56
|
+
empty: status.empty?
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def presence_hash(presence)
|
|
61
|
+
return nil unless presence
|
|
62
|
+
|
|
63
|
+
{ presence: presence[:presence], manual_away: presence[:manual_away], online: presence[:online] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dnd_hash(dnd)
|
|
67
|
+
return nil unless dnd
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
active: dnd.active?,
|
|
71
|
+
source: dnd.source&.to_s,
|
|
72
|
+
snoozing: dnd.snoozing,
|
|
73
|
+
in_scheduled_hours: dnd.in_scheduled_hours?,
|
|
74
|
+
until: dnd.until_time&.to_i,
|
|
75
|
+
until_at: iso8601(dnd.until_time)
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Slack's own field names and epochs, plus a readable form of each
|
|
80
|
+
# timestamp. `slice` rather than the whole record on purpose: this is a
|
|
81
|
+
# published shape, and a field added to the model later should not join
|
|
82
|
+
# it without someone deciding to.
|
|
83
|
+
def scheduled_hash(status)
|
|
84
|
+
status.to_h.slice(:id, :text, :emoji, :date_scheduled, :date_expire, :dnd, :active)
|
|
85
|
+
.merge(starts_at: iso8601(status.starts_at), ends_at: iso8601(status.ends_at))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def iso8601(time) = time&.iso8601
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Slk
|
|
6
|
+
module Models
|
|
7
|
+
# Current Do Not Disturb state for one workspace, from dnd.info.
|
|
8
|
+
#
|
|
9
|
+
# Slack reports two independent things through that one endpoint: a manual
|
|
10
|
+
# snooze ("pause notifications for 2 hours") and the configured DND
|
|
11
|
+
# schedule ("quiet from 8pm to 8am"). From the outside they are the same
|
|
12
|
+
# thing — messages do not notify — so `active?` covers both and `source`
|
|
13
|
+
# says which one is responsible.
|
|
14
|
+
DndState = Data.define(:snoozing, :snooze_endtime, :scheduled, :next_start, :next_end) do
|
|
15
|
+
def self.from_api(data)
|
|
16
|
+
data = {} unless data.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
new(
|
|
19
|
+
snoozing: data['snooze_enabled'] == true,
|
|
20
|
+
snooze_endtime: data['snooze_endtime'].to_i,
|
|
21
|
+
scheduled: data['dnd_enabled'] == true,
|
|
22
|
+
next_start: data['next_dnd_start_ts'].to_i,
|
|
23
|
+
next_end: data['next_dnd_end_ts'].to_i
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def active? = snoozing || in_scheduled_hours?
|
|
28
|
+
|
|
29
|
+
# `next_dnd_*` names the *next* window while DND hours are off and the
|
|
30
|
+
# current one while they are running, so "now falls inside it" is the
|
|
31
|
+
# only reading that means notifications are being held right now.
|
|
32
|
+
#
|
|
33
|
+
# Slack reports 1 for both schedule timestamps when no schedule is
|
|
34
|
+
# configured, so a bare `positive?` would read that as a window that
|
|
35
|
+
# opened in 1970 and never closed.
|
|
36
|
+
def in_scheduled_hours?
|
|
37
|
+
return false unless scheduled && next_start > 1 && next_end > next_start
|
|
38
|
+
|
|
39
|
+
now = Time.now.to_i
|
|
40
|
+
next_start <= now && now < next_end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def source
|
|
44
|
+
return :both if snoozing && in_scheduled_hours?
|
|
45
|
+
return :snooze if snoozing
|
|
46
|
+
return :schedule if in_scheduled_hours?
|
|
47
|
+
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The later of the two when both apply: notifications stay off until
|
|
52
|
+
# every reason for holding them has expired, not the first.
|
|
53
|
+
def until_time
|
|
54
|
+
finishes = []
|
|
55
|
+
finishes << snooze_endtime if snoozing && snooze_endtime.positive?
|
|
56
|
+
finishes << next_end if in_scheduled_hours?
|
|
57
|
+
|
|
58
|
+
finish = finishes.max
|
|
59
|
+
finish ? Time.at(finish) : nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Same-day times need no date; an overnight schedule ending tomorrow
|
|
63
|
+
# morning would otherwise read as "until 8:00am" of a day already gone.
|
|
64
|
+
def until_label
|
|
65
|
+
finish = until_time or return nil
|
|
66
|
+
finish.to_date == Date.today ? finish.strftime('%-l:%M%P') : finish.strftime('%a %-l:%M%P')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Empty when notifications are flowing: there is nothing to say, and a
|
|
70
|
+
# "[dnd off]" on every line would bury the workspace where it is on.
|
|
71
|
+
def to_s
|
|
72
|
+
return '' unless active?
|
|
73
|
+
|
|
74
|
+
label = until_label
|
|
75
|
+
# A snooze with no end time is a state Slack can report; saying "[dnd]"
|
|
76
|
+
# is honest about not knowing when it lifts.
|
|
77
|
+
label ? "[dnd until #{label}]" : '[dnd]'
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# Everything `slk status` knows about one workspace at one moment: the
|
|
6
|
+
# status itself, plus the two things that decide whether anyone can reach
|
|
7
|
+
# you (presence, DND) and whatever is queued to replace the status later.
|
|
8
|
+
#
|
|
9
|
+
# Every part but the status is optional, and nil means "not checked" —
|
|
10
|
+
# skipped by a flag, or a lookup that failed. That is deliberately distinct
|
|
11
|
+
# from checked-and-empty: `scheduled: []` says nothing is queued, while
|
|
12
|
+
# `scheduled: nil` says nobody looked.
|
|
13
|
+
StatusSnapshot = Data.define(:workspace, :status, :presence, :dnd, :scheduled) do
|
|
14
|
+
def initialize(workspace:, status:, presence: nil, dnd: nil, scheduled: nil)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def workspace_name = workspace.name
|
|
19
|
+
|
|
20
|
+
def away? = presence ? presence[:presence] == 'away' : false
|
|
21
|
+
|
|
22
|
+
# Short suffixes for the status line. Only the exceptional states appear:
|
|
23
|
+
# active presence and DND-off are the common case, and repeating them on
|
|
24
|
+
# every line would bury the workspace that differs.
|
|
25
|
+
def labels
|
|
26
|
+
[('[away]' if away?), dnd&.to_s].reject { |label| label.to_s.empty? }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -67,6 +67,8 @@ module Slk
|
|
|
67
67
|
autoload :Workspace, 'slk/models/workspace'
|
|
68
68
|
autoload :Status, 'slk/models/status'
|
|
69
69
|
autoload :ScheduledStatus, 'slk/models/scheduled_status'
|
|
70
|
+
autoload :DndState, 'slk/models/dnd_state'
|
|
71
|
+
autoload :StatusSnapshot, 'slk/models/status_snapshot'
|
|
70
72
|
autoload :Message, 'slk/models/message'
|
|
71
73
|
autoload :Reaction, 'slk/models/reaction'
|
|
72
74
|
autoload :User, 'slk/models/user'
|
|
@@ -117,6 +119,7 @@ module Slk
|
|
|
117
119
|
autoload :MessageFormatter, 'slk/formatters/message_formatter'
|
|
118
120
|
autoload :ReactionFormatter, 'slk/formatters/reaction_formatter'
|
|
119
121
|
autoload :JsonMessageFormatter, 'slk/formatters/json_message_formatter'
|
|
122
|
+
autoload :JsonStatusFormatter, 'slk/formatters/json_status_formatter'
|
|
120
123
|
autoload :ActivityFormatter, 'slk/formatters/activity_formatter'
|
|
121
124
|
autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
|
|
122
125
|
autoload :BlockFormatter, 'slk/formatters/block_formatter'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: slk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/slk/formatters/duration_formatter.rb
|
|
68
68
|
- lib/slk/formatters/emoji_replacer.rb
|
|
69
69
|
- lib/slk/formatters/json_message_formatter.rb
|
|
70
|
+
- lib/slk/formatters/json_status_formatter.rb
|
|
70
71
|
- lib/slk/formatters/markdown_output.rb
|
|
71
72
|
- lib/slk/formatters/mention_replacer.rb
|
|
72
73
|
- lib/slk/formatters/message_formatter.rb
|
|
@@ -79,6 +80,7 @@ files:
|
|
|
79
80
|
- lib/slk/formatters/search_formatter.rb
|
|
80
81
|
- lib/slk/formatters/text_processor.rb
|
|
81
82
|
- lib/slk/models/channel.rb
|
|
83
|
+
- lib/slk/models/dnd_state.rb
|
|
82
84
|
- lib/slk/models/duration.rb
|
|
83
85
|
- lib/slk/models/message.rb
|
|
84
86
|
- lib/slk/models/preset.rb
|
|
@@ -89,6 +91,7 @@ files:
|
|
|
89
91
|
- lib/slk/models/scheduled_status.rb
|
|
90
92
|
- lib/slk/models/search_result.rb
|
|
91
93
|
- lib/slk/models/status.rb
|
|
94
|
+
- lib/slk/models/status_snapshot.rb
|
|
92
95
|
- lib/slk/models/user.rb
|
|
93
96
|
- lib/slk/models/workspace.rb
|
|
94
97
|
- lib/slk/runner.rb
|
|
@@ -152,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
152
155
|
- !ruby/object:Gem::Version
|
|
153
156
|
version: '0'
|
|
154
157
|
requirements: []
|
|
155
|
-
rubygems_version: 4.0.
|
|
158
|
+
rubygems_version: 4.0.16
|
|
156
159
|
specification_version: 4
|
|
157
160
|
summary: A command-line interface for Slack
|
|
158
161
|
test_files: []
|