slk 0.6.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 +50 -1
- data/README.md +72 -1
- data/lib/slk/api/custom_status.rb +112 -0
- data/lib/slk/cli.rb +5 -2
- data/lib/slk/commands/base.rb +13 -1
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/status.rb +363 -27
- data/lib/slk/formatters/json_status_formatter.rb +91 -0
- data/lib/slk/models/dnd_state.rb +81 -0
- data/lib/slk/models/scheduled_status.rb +77 -0
- data/lib/slk/models/status_snapshot.rb +30 -0
- data/lib/slk/runner.rb +4 -0
- data/lib/slk/services/encryption.rb +8 -1
- data/lib/slk/support/time_parser.rb +120 -0
- data/lib/slk/support/time_range_parser.rb +166 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +18 -0
- metadata +9 -2
data/lib/slk/commands/status.rb
CHANGED
|
@@ -10,6 +10,10 @@ module Slk
|
|
|
10
10
|
class Status < Base
|
|
11
11
|
include Support::InlineImages
|
|
12
12
|
|
|
13
|
+
SCHEDULE_USAGE = 'Usage: slk status schedule "<text>" [:emoji:] <start-end> | --start WHEN [--end WHEN]'
|
|
14
|
+
MISSING_RANGE = 'Missing time range. Example: slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p ' \
|
|
15
|
+
'(or --start/--end for a multi-day window).'
|
|
16
|
+
|
|
13
17
|
def execute
|
|
14
18
|
result = validate_options
|
|
15
19
|
return result if result
|
|
@@ -22,6 +26,9 @@ module Slk
|
|
|
22
26
|
|
|
23
27
|
def dispatch_action
|
|
24
28
|
case positional_args
|
|
29
|
+
in ['schedule', *rest] then schedule_status(rest)
|
|
30
|
+
in ['scheduled', *] then list_scheduled
|
|
31
|
+
in ['unschedule', *rest] then unschedule_status(rest)
|
|
25
32
|
in ['clear', *] then clear_status
|
|
26
33
|
in [text, *rest] then set_status(text, rest)
|
|
27
34
|
in [] then get_status
|
|
@@ -31,32 +38,52 @@ module Slk
|
|
|
31
38
|
protected
|
|
32
39
|
|
|
33
40
|
def default_options
|
|
34
|
-
super.merge(presence: nil, dnd: nil
|
|
41
|
+
super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil,
|
|
42
|
+
scheduled: true, brief: false)
|
|
35
43
|
end
|
|
36
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
|
+
|
|
37
50
|
def handle_option(arg, args, remaining)
|
|
51
|
+
return toggle(arg) if TOGGLES.key?(arg)
|
|
52
|
+
|
|
38
53
|
case arg
|
|
39
|
-
when '-p', '--presence'
|
|
40
|
-
|
|
41
|
-
when '
|
|
42
|
-
|
|
43
|
-
else
|
|
44
|
-
super
|
|
54
|
+
when '-p', '--presence' then @options[:presence] = option_value(arg, args)
|
|
55
|
+
when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
|
|
56
|
+
when '--start' then @options[:start_at] = option_value(arg, args)
|
|
57
|
+
when '--end' then @options[:end_at] = option_value(arg, args)
|
|
58
|
+
else super
|
|
45
59
|
end
|
|
46
60
|
end
|
|
47
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
|
+
|
|
48
69
|
def help_text
|
|
49
70
|
help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
|
|
50
71
|
help.description('Get or set your Slack status.')
|
|
51
|
-
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.')
|
|
74
|
+
help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
|
|
75
|
+
help.note('Slack allows at most 5 scheduled statuses at a time.')
|
|
52
76
|
add_examples_section(help)
|
|
77
|
+
add_scheduling_section(help)
|
|
53
78
|
add_options_section(help)
|
|
54
79
|
help.render
|
|
55
80
|
end
|
|
56
81
|
|
|
57
82
|
def add_examples_section(help)
|
|
58
83
|
help.section('EXAMPLES') do |s|
|
|
59
|
-
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"')
|
|
60
87
|
s.example('slk status clear', 'Clear status')
|
|
61
88
|
s.example('slk status "Working" :laptop:', 'Set status with emoji')
|
|
62
89
|
s.example('slk status "Meeting" :calendar: 1h', 'Set status for 1 hour')
|
|
@@ -64,52 +91,166 @@ module Slk
|
|
|
64
91
|
end
|
|
65
92
|
end
|
|
66
93
|
|
|
94
|
+
def add_scheduling_section(help)
|
|
95
|
+
help.section('SCHEDULING') do |s|
|
|
96
|
+
s.example('slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p', 'Schedule for later')
|
|
97
|
+
s.example('slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00')
|
|
98
|
+
s.example('slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"',
|
|
99
|
+
'Span multiple days')
|
|
100
|
+
s.example('slk status schedule "Heads down" :no_bell: --start 2p', 'No end; stays until cleared')
|
|
101
|
+
s.example('slk status scheduled', 'List pending scheduled statuses')
|
|
102
|
+
s.example('slk status unschedule CS0BMQDDGWTU', 'Cancel a scheduled status')
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
67
106
|
def add_options_section(help)
|
|
68
107
|
help.section('OPTIONS') do |s|
|
|
69
|
-
s
|
|
70
|
-
s
|
|
71
|
-
s
|
|
72
|
-
s.option('--all', 'Set across all workspaces')
|
|
73
|
-
s.option('-v, --verbose', 'Show debug information')
|
|
74
|
-
s.option('-q, --quiet', 'Suppress output')
|
|
108
|
+
add_general_options(s)
|
|
109
|
+
add_getting_options(s)
|
|
110
|
+
add_scheduling_options(s)
|
|
75
111
|
end
|
|
76
112
|
end
|
|
77
113
|
|
|
114
|
+
def add_general_options(section)
|
|
115
|
+
section.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
|
|
116
|
+
section.option('-d, --dnd DURATION', "Also set DND (or 'off')")
|
|
117
|
+
section.option('-w, --workspace', 'Limit to specific workspace')
|
|
118
|
+
section.option('--all', 'Set across all workspaces')
|
|
119
|
+
section.option('-v, --verbose', 'Show debug information')
|
|
120
|
+
section.option('-q, --quiet', 'Suppress output')
|
|
121
|
+
end
|
|
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
|
+
|
|
131
|
+
# These are ignored outside `schedule`, so say so rather than leaving
|
|
132
|
+
# --with-dnd looking like a sibling of -d/--dnd.
|
|
133
|
+
def add_scheduling_options(section)
|
|
134
|
+
section.option('--with-dnd', 'Scheduling only: pause notifications while the status is active')
|
|
135
|
+
section.option('--start WHEN', 'Scheduling only: window start, "[YYYY-MM-DD ]TIME"')
|
|
136
|
+
section.option('--end WHEN', 'Scheduling only: window end; omit for no expiry')
|
|
137
|
+
end
|
|
138
|
+
|
|
78
139
|
private
|
|
79
140
|
|
|
80
141
|
def get_status # rubocop:disable Naming/AccessorMethodName
|
|
81
142
|
# GET defaults to all workspaces unless -w specified
|
|
82
143
|
workspaces = target_workspaces_for_get
|
|
144
|
+
snapshots = workspaces.map { |workspace| snapshot_for(workspace) }
|
|
145
|
+
return render_snapshots_json(snapshots) if @options[:json]
|
|
83
146
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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)
|
|
87
184
|
end
|
|
185
|
+
end
|
|
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
|
|
88
205
|
|
|
206
|
+
def render_snapshots_json(snapshots)
|
|
207
|
+
output_json(json_formatter.format(snapshots))
|
|
89
208
|
0
|
|
90
209
|
end
|
|
91
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
|
+
|
|
92
229
|
def target_workspaces_for_get
|
|
93
230
|
@options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
|
|
94
231
|
end
|
|
95
232
|
|
|
96
|
-
def
|
|
97
|
-
|
|
233
|
+
def print_status_line(snapshot)
|
|
234
|
+
suffix = snapshot.labels.join(' ')
|
|
98
235
|
|
|
99
|
-
if status.empty?
|
|
100
|
-
|
|
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)
|
|
101
240
|
else
|
|
102
|
-
display_status(workspace, status)
|
|
241
|
+
display_status(snapshot.workspace, snapshot.status, suffix)
|
|
103
242
|
end
|
|
104
243
|
end
|
|
105
244
|
|
|
106
|
-
def
|
|
245
|
+
def join_suffix(line, suffix) = suffix.empty? ? line : "#{line} #{suffix}"
|
|
246
|
+
|
|
247
|
+
def display_status(workspace, status, suffix = '')
|
|
107
248
|
emoji_path = workspace_emoji_path(workspace.name, status.emoji)
|
|
108
249
|
|
|
109
250
|
if emoji_path && inline_images_supported?
|
|
110
|
-
print_status_with_image(emoji_path, status)
|
|
251
|
+
print_status_with_image(emoji_path, status, suffix)
|
|
111
252
|
else
|
|
112
|
-
puts " #{status}"
|
|
253
|
+
puts join_suffix(" #{status}", suffix)
|
|
113
254
|
end
|
|
114
255
|
end
|
|
115
256
|
|
|
@@ -118,10 +259,11 @@ module Slk
|
|
|
118
259
|
find_workspace_emoji(workspace_name, emoji_name)
|
|
119
260
|
end
|
|
120
261
|
|
|
121
|
-
def print_status_with_image(emoji_path, status)
|
|
262
|
+
def print_status_with_image(emoji_path, status, suffix = '')
|
|
122
263
|
parts = []
|
|
123
264
|
parts << status.text unless status.text.empty?
|
|
124
265
|
parts << "(#{status.time_remaining})" if status.time_remaining
|
|
266
|
+
parts << suffix unless suffix.empty?
|
|
125
267
|
print_inline_image_with_text(emoji_path, " #{parts.join(' ')}")
|
|
126
268
|
end
|
|
127
269
|
|
|
@@ -210,6 +352,200 @@ module Slk
|
|
|
210
352
|
0
|
|
211
353
|
end
|
|
212
354
|
|
|
355
|
+
def schedule_status(args)
|
|
356
|
+
text, *rest = args
|
|
357
|
+
return error(SCHEDULE_USAGE) if text.to_s.strip.empty?
|
|
358
|
+
|
|
359
|
+
window = parse_schedule_window(rest)
|
|
360
|
+
return 1 unless window
|
|
361
|
+
|
|
362
|
+
result = create_scheduled_status(text, extract_emoji(rest), *window)
|
|
363
|
+
show_all_workspaces_hint
|
|
364
|
+
result
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Narrow on both axes: only the parsing is covered, so a Slack failure is
|
|
368
|
+
# not reported as a mistyped range, and only TimeFormatError is caught, so
|
|
369
|
+
# an arity or range error from this code does not surface as user error.
|
|
370
|
+
def parse_schedule_window(rest)
|
|
371
|
+
return flag_window(rest) if @options[:start_at] || @options[:end_at]
|
|
372
|
+
|
|
373
|
+
range = extract_time_range(rest)
|
|
374
|
+
return reject_window(MISSING_RANGE) unless range
|
|
375
|
+
|
|
376
|
+
Support::TimeRangeParser.parse(range)
|
|
377
|
+
rescue TimeFormatError => e
|
|
378
|
+
error(e.message)
|
|
379
|
+
nil
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# --start/--end is the general form. Both times carry their own date, so
|
|
383
|
+
# it reaches multi-day windows the positional range cannot express, and
|
|
384
|
+
# omitting --end schedules a status that never auto-clears.
|
|
385
|
+
def flag_window(rest)
|
|
386
|
+
return reject_window('--end requires --start.') unless @options[:start_at]
|
|
387
|
+
return reject_window('Use either a time range or --start/--end, not both.') if extract_time_range(rest)
|
|
388
|
+
|
|
389
|
+
starts_at = Support::TimeParser.parse(@options[:start_at])
|
|
390
|
+
return [starts_at, nil] unless @options[:end_at]
|
|
391
|
+
|
|
392
|
+
[starts_at, validated_end(starts_at)]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def validated_end(starts_at)
|
|
396
|
+
ends_at = Support::TimeParser.parse(@options[:end_at])
|
|
397
|
+
# An explicit end is unambiguous, so there is nothing to roll forward.
|
|
398
|
+
return ends_at if ends_at > starts_at
|
|
399
|
+
|
|
400
|
+
raise TimeFormatError, "--end #{@options[:end_at]} is not after --start #{@options[:start_at]}."
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Returns nil so the caller reads it as "no window, already reported".
|
|
404
|
+
def reject_window(message)
|
|
405
|
+
error(message)
|
|
406
|
+
nil
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The range may arrive as one token ("1:30p-3:30p") or several
|
|
410
|
+
# ("2026-08-04 13:30-15:30"), so the non-emoji arguments are matched as a
|
|
411
|
+
# whole. Emoji are dropped first because the pattern is \A-anchored and a
|
|
412
|
+
# leading ":palm_tree:" would stop the date form from matching at all.
|
|
413
|
+
#
|
|
414
|
+
# Everything left has to be part of the range. Only YYYY-MM-DD dates
|
|
415
|
+
# parse, so picking the range out and discarding "8/4" or "tomorrow"
|
|
416
|
+
# would silently schedule the status for today.
|
|
417
|
+
def extract_time_range(rest)
|
|
418
|
+
candidates = rest.reject { |arg| arg.start_with?(':') && arg.end_with?(':') }
|
|
419
|
+
return nil if candidates.empty?
|
|
420
|
+
|
|
421
|
+
joined = candidates.join(' ')
|
|
422
|
+
return joined if Support::TimeRangeParser.match?(joined)
|
|
423
|
+
|
|
424
|
+
raise TimeFormatError, "Unrecognized argument: #{joined}. Use #{Support::TimeRangeParser::EXAMPLE}"
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Each workspace is an independent call, so one failure must not strand
|
|
428
|
+
# the workspaces after it: the user would re-run to fix the tail and
|
|
429
|
+
# double-schedule everything that already succeeded.
|
|
430
|
+
def each_workspace_reporting(workspaces)
|
|
431
|
+
failed = false
|
|
432
|
+
|
|
433
|
+
workspaces.each do |workspace|
|
|
434
|
+
yield workspace
|
|
435
|
+
rescue ApiError => e
|
|
436
|
+
failed = true
|
|
437
|
+
error("#{workspace.name}: #{e.message}")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
failed ? 1 : 0
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def create_scheduled_status(text, emoji, starts_at, ends_at)
|
|
444
|
+
each_workspace_reporting(target_workspaces) do |workspace|
|
|
445
|
+
scheduled = runner.custom_status_api(workspace.name).schedule(
|
|
446
|
+
text: text, emoji: emoji,
|
|
447
|
+
date_scheduled: starts_at, date_expire: ends_at,
|
|
448
|
+
dnd: @options[:with_dnd]
|
|
449
|
+
)
|
|
450
|
+
success("Scheduled on #{workspace.name}: #{scheduled}")
|
|
451
|
+
debug(" ID: #{scheduled.id}")
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def list_scheduled
|
|
456
|
+
workspaces = target_workspaces_for_get
|
|
457
|
+
return list_scheduled_json(workspaces) if @options[:json]
|
|
458
|
+
|
|
459
|
+
each_workspace_reporting(workspaces) do |workspace|
|
|
460
|
+
puts output.bold(workspace.name) if workspaces.size > 1
|
|
461
|
+
print_scheduled(runner.custom_status_api(workspace.name).scheduled)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
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
|
+
|
|
479
|
+
def print_scheduled(scheduled)
|
|
480
|
+
return puts ' (none scheduled)' if scheduled.empty?
|
|
481
|
+
|
|
482
|
+
in_order(scheduled).each { |status| puts " #{status.id} #{status}" }
|
|
483
|
+
end
|
|
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
|
+
|
|
488
|
+
def unschedule_status(args)
|
|
489
|
+
id = args.first
|
|
490
|
+
return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
|
|
491
|
+
|
|
492
|
+
@unchecked = []
|
|
493
|
+
targets = unschedule_targets(id)
|
|
494
|
+
return report_id_not_found(id) if targets.empty?
|
|
495
|
+
|
|
496
|
+
each_workspace_reporting(targets) do |workspace|
|
|
497
|
+
outcome, reason = runner.custom_status_api(workspace.name).delete_scheduled(id)
|
|
498
|
+
report_cancelled(workspace, outcome, reason)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# The delete succeeded either way, so both are successes — but only one
|
|
503
|
+
# of them has been checked, and saying so is the difference between the
|
|
504
|
+
# user moving on and the user re-cancelling something already gone.
|
|
505
|
+
def report_cancelled(workspace, outcome, reason)
|
|
506
|
+
return success("Cancelled scheduled status on #{workspace.name}") if outcome == :cancelled
|
|
507
|
+
|
|
508
|
+
warn("Cancelled scheduled status on #{workspace.name}, but could not confirm it: #{reason}")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# `slk status scheduled` lists every workspace, so the obvious next step
|
|
512
|
+
# is to paste an ID straight back in. Defaulting to the primary workspace
|
|
513
|
+
# made that fail with a bare Slack error whenever the ID came from
|
|
514
|
+
# another one, so look up the owner instead. An explicit -w/--all still
|
|
515
|
+
# wins, and a lone workspace needs no lookup.
|
|
516
|
+
def unschedule_targets(id)
|
|
517
|
+
return target_workspaces if @options[:all] || @options[:workspace]
|
|
518
|
+
|
|
519
|
+
workspaces = runner.all_workspaces
|
|
520
|
+
return workspaces if workspaces.size <= 1
|
|
521
|
+
|
|
522
|
+
[workspaces.find { |workspace| owns_scheduled?(workspace, id) }].compact
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# "Not found" is only true of the workspaces we actually reached. Saying
|
|
526
|
+
# it flatly after warning that one could not be checked contradicts the
|
|
527
|
+
# warning, and pointing at `slk status scheduled` would just fail the
|
|
528
|
+
# same way — it calls the same endpoint.
|
|
529
|
+
def report_id_not_found(id)
|
|
530
|
+
return error("No scheduled status #{id} found. Run 'slk status scheduled' to list IDs.") if @unchecked.empty?
|
|
531
|
+
|
|
532
|
+
error("#{id} was not found, but #{@unchecked.join(', ')} could not be checked. " \
|
|
533
|
+
'Retry, or name the workspace with -w to cancel it directly.')
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def owns_scheduled?(workspace, id)
|
|
537
|
+
runner.custom_status_api(workspace.name).scheduled.any? { |status| status.id == id }
|
|
538
|
+
rescue RateLimitError
|
|
539
|
+
# Every remaining check spends another call against the same limit, so
|
|
540
|
+
# continuing turns one rate limit into several and still cannot answer.
|
|
541
|
+
raise
|
|
542
|
+
rescue ApiError => e
|
|
543
|
+
# Skipping it silently would report "not found" for an ID that exists.
|
|
544
|
+
warn("Could not check #{workspace.name}: #{e.message}")
|
|
545
|
+
@unchecked << workspace.name
|
|
546
|
+
false
|
|
547
|
+
end
|
|
548
|
+
|
|
213
549
|
def show_all_workspaces_hint
|
|
214
550
|
# Show hint if user has multiple workspaces and didn't use --all or -w
|
|
215
551
|
return if @options[:all] || @options[:workspace]
|
|
@@ -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
|