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.
@@ -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
- @options[:presence] = args.shift
41
- when '-d', '--dnd'
42
- @options[:dnd] = args.shift
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 all workspaces by default. SET applies to primary only.')
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.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
70
- s.option('-d, --dnd DURATION', "Also set DND (or 'off')")
71
- s.option('-w, --workspace', 'Limit to specific workspace')
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
- workspaces.each do |workspace|
85
- status = runner.users_api(workspace.name).get_status
86
- print_workspace_status(workspaces, workspace, status)
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 print_workspace_status(workspaces, workspace, status)
97
- puts output.bold(workspace.name) if workspaces.size > 1
233
+ def print_status_line(snapshot)
234
+ suffix = snapshot.labels.join(' ')
98
235
 
99
- if status.empty?
100
- puts ' (no status set)'
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 display_status(workspace, status)
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