slk 0.6.0 → 0.7.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 +30 -1
- data/README.md +31 -0
- 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 +224 -13
- data/lib/slk/models/scheduled_status.rb +77 -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 +15 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 895e4fdd405a7b4f560424a93d4906ea7330088763a5559b2d581788908794b3
|
|
4
|
+
data.tar.gz: 6f8c66d9cd6555153db454d99d7cafdf4e82bbaac2f6349edee1427047e31104
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7394318fac53168225d5ff30a249612a0f756fe60eaca67141ca937d43f8c70aaa7a1066681bb35a97fc49f242254d87438f369b02700193cc86456ded97ca9c
|
|
7
|
+
data.tar.gz: ea890ffc51e6540c6a19db3481d92b8f0263c042594f74c4ec2ac3f975c7f988d345f69e0a6b37345993b499535180b87456cd189dffa9506b2c60148f2b9481
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,34 @@ 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
|
-
## [
|
|
8
|
+
## [0.7.0] - 2026-08-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`slk status schedule|scheduled|unschedule`** — queue statuses to turn on later
|
|
13
|
+
- `slk status schedule "<text>" [:emoji:] <start-end>` schedules a status; emoji defaults to `:speech_balloon:`
|
|
14
|
+
- Time windows accept bare 12- or 24-hour times (`1:30p-3:30p`, `13:30-15:30`) or an explicit `YYYY-MM-DD` date (`2026-08-04 9:00-17:00`)
|
|
15
|
+
- Bare times resolve forward: a window at or before now rolls to tomorrow, and an end before the start crosses midnight (`11p-1a`)
|
|
16
|
+
- A single am/pm carries across the range, so `1-3p` is 1pm to 3pm — unless that would invert it, leaving `9-5p` as 9am to 5pm
|
|
17
|
+
- Ambiguous or impossible windows are rejected rather than guessed: `9-5` and `9a-5` (both read as crossing midnight and spanning 20 hours), `1p-1p`, a time DST skips, and unrecognized date forms such as `8/4` or `tomorrow`
|
|
18
|
+
- The check applies only to a reading that had to be guessed, and only past 12 hours, so windows that say what they mean still work: `11p-1a`, `8p-9a`, `20:00-09:00`, `20:00-6`, and `9p-5`
|
|
19
|
+
- `--start WHEN` / `--end WHEN` take `[YYYY-MM-DD ]TIME` each, for windows the single-date range cannot express: `--start "2026-08-12 8a" --end "2026-08-14 5p"`. Omitting `--end` schedules a status with no expiry
|
|
20
|
+
- `--with-dnd` also pauses notifications while the status is active
|
|
21
|
+
- `slk status scheduled` lists pending statuses with their IDs across every workspace; `slk status unschedule <id>` looks up which workspace owns the ID rather than assuming the primary one (`-w`/`--all` still override)
|
|
22
|
+
- `slk status scheduled` marks the one Slack reports as currently applied with `[active]`
|
|
23
|
+
- Backed by Slack's internal `users.customStatus.*` endpoints, which require form-encoded bodies and only return the scheduled section when `statuses_count_per_section` is sent. Responses are checked rather than trusted: an absent `scheduled_statuses` section, a create that echoes nothing back, a status payload with no id, and a delete the following list still shows all raise instead of reporting success
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- New `Slk::UsageError` (bad invocation) and `Slk::TimeFormatError` (unparseable time) error types. `slk` prints them without an error-type label, and callers can rescue malformed input without also swallowing arity or range errors from their own code
|
|
28
|
+
- Flags that take a value now reject a missing one or a following flag instead of shifting `nil`. This covers `--start`, `--end`, `-p` and `-d` on `status`, and `-w`/`--workspace` everywhere — a trailing `slk status -w` previously applied to *every* workspace and exited 0
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `slk status unschedule` no longer reports a successful cancel as a failure when the confirming re-read fails (a network error, a rate limit, or Slack dropping the `scheduled_statuses` section — which is exactly what happens when you cancel your only scheduled status). The cancel is reported with an explicit "could not confirm" caveat and exit 0; only a status still demonstrably present is an error
|
|
33
|
+
- `slk status schedule` again reports an unusable Slack response as "check the status picker to see whether it was created" rather than the less actionable "returned a scheduled status with no id"
|
|
34
|
+
- `Slk::UsageError` no longer files a backtrace in `~/.cache/slk/error.log`; a mistyped flag is not a fault to investigate later
|
|
35
|
+
- SSH key validation no longer hangs on Windows when the private key is passphrase-protected. `ssh-keygen` prompts on the console rather than on stdin, so the prompt could not be answered or dismissed; it is now given an empty passphrase up front and reports the unsupported key instead of waiting
|
|
9
36
|
|
|
10
37
|
## [0.6.0] - 2026-04-27
|
|
11
38
|
|
|
@@ -183,6 +210,8 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
|
|
|
183
210
|
- Pure Ruby stdlib - no gem dependencies
|
|
184
211
|
- Ruby 3.2+ with modern features (Data.define, pattern matching)
|
|
185
212
|
|
|
213
|
+
[0.7.0]: https://github.com/ericboehs/slk/releases/tag/v0.7.0
|
|
214
|
+
[0.6.0]: https://github.com/ericboehs/slk/releases/tag/v0.6.0
|
|
186
215
|
[0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
|
|
187
216
|
[0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
|
|
188
217
|
[0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
|
data/README.md
CHANGED
|
@@ -52,6 +52,37 @@ slk status "In a meeting" :calendar: 1h # Set status for 1 hour
|
|
|
52
52
|
slk status clear # Clear status
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
Statuses can also be scheduled to turn on later (Slack allows up to 5 at a time):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p # Bare times; rolls to tomorrow if past
|
|
59
|
+
slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00 # Explicit date
|
|
60
|
+
slk status schedule "Heads down" :no_bell: 11p-1a --with-dnd # Overnight, pausing notifications
|
|
61
|
+
slk status scheduled # List pending (all workspaces)
|
|
62
|
+
slk status unschedule CS0BMQDDGWTU # Cancel one (finds the owning workspace)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A single am/pm carries across the range, so `1-3p` is 1pm to 3pm — unless that
|
|
66
|
+
would invert it, leaving `9-5p` as 9am to 5pm. Anything the am/pm cannot settle
|
|
67
|
+
is read as a 24-hour time, which is usually right but occasionally means the
|
|
68
|
+
opposite of what you typed: `9-5` and `9a-5` both come out as 9am until 5am the
|
|
69
|
+
next morning. Windows that land that way — guessed, crossing midnight, and over
|
|
70
|
+
12 hours — are rejected rather than scheduled, so add the second am/pm (`9a-5p`)
|
|
71
|
+
or use 24-hour times (`9:00-17:00`).
|
|
72
|
+
|
|
73
|
+
Overnight windows are fine when they say so. A meridiem on both sides (`11p-1a`,
|
|
74
|
+
`8p-9a`) or a 24-hour time on either side (`20:00-09:00`, `20:00-6`) settles the
|
|
75
|
+
range, and a pm start is enough on its own: `9p-5` is 9pm to 5am.
|
|
76
|
+
|
|
77
|
+
A `start-end` range writes the date once and the end can only reach the next
|
|
78
|
+
day, so multi-day windows use `--start` / `--end` instead. Each takes
|
|
79
|
+
`[YYYY-MM-DD ]TIME`, and omitting `--end` schedules a status with no expiry:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"
|
|
83
|
+
slk status schedule "Heads down" :no_bell: --start 2p # Stays until cleared
|
|
84
|
+
```
|
|
85
|
+
|
|
55
86
|
### Presence
|
|
56
87
|
|
|
57
88
|
```bash
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Api
|
|
5
|
+
# Wrapper for Slack's internal users.customStatus.* endpoints, which back
|
|
6
|
+
# the "Scheduled" section of the status picker.
|
|
7
|
+
#
|
|
8
|
+
# Two undocumented quirks these methods paper over:
|
|
9
|
+
# - Only form-encoded bodies are accepted. A JSON body is ignored and the
|
|
10
|
+
# call fails with invalid_arguments naming every field as missing.
|
|
11
|
+
# - list omits scheduled_statuses entirely unless
|
|
12
|
+
# statuses_count_per_section is passed.
|
|
13
|
+
class CustomStatus
|
|
14
|
+
DEFAULT_SECTION_COUNT = 20
|
|
15
|
+
|
|
16
|
+
def initialize(api_client, workspace)
|
|
17
|
+
@api = api_client
|
|
18
|
+
@workspace = workspace
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Hash] raw response; 'statuses' (recent) and 'scheduled_statuses'
|
|
22
|
+
# (pending) are each absent when Slack omits the section
|
|
23
|
+
def list(count_per_section: DEFAULT_SECTION_COUNT)
|
|
24
|
+
@api.post_form(@workspace, 'users.customStatus.list',
|
|
25
|
+
{ statuses_count_per_section: count_per_section.to_s })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Array<Models::ScheduledStatus>] pending statuses only
|
|
29
|
+
def scheduled(count_per_section: DEFAULT_SECTION_COUNT)
|
|
30
|
+
response = list(count_per_section: count_per_section)
|
|
31
|
+
section = response['scheduled_statuses']
|
|
32
|
+
# An absent section is a protocol change, not an empty list. Reporting
|
|
33
|
+
# it as "none scheduled" would invite the user to re-create statuses
|
|
34
|
+
# that still exist.
|
|
35
|
+
unless section.is_a?(Array)
|
|
36
|
+
raise ApiError.new('Slack returned no scheduled_statuses section; this internal endpoint may have changed. ' \
|
|
37
|
+
'Check the Slack status picker before re-scheduling.',
|
|
38
|
+
code: :missing_scheduled_section)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
section.map { |item| Models::ScheduledStatus.from_api(item) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param date_scheduled [Integer] Unix timestamp the status turns on
|
|
45
|
+
# @param date_expire [Integer, nil] Unix timestamp it clears
|
|
46
|
+
# @param dnd [Boolean] also pause notifications while active
|
|
47
|
+
# @return [Models::ScheduledStatus]
|
|
48
|
+
def schedule(text:, emoji:, date_scheduled:, date_expire: nil, dnd: false)
|
|
49
|
+
params = { text: text, emoji: emoji, date_scheduled: date_scheduled.to_i.to_s }
|
|
50
|
+
params[:date_expire] = date_expire.to_i.to_s if date_expire
|
|
51
|
+
params[:is_dnd] = 'true' if dnd
|
|
52
|
+
|
|
53
|
+
response = @api.post_form(@workspace, 'users.customStatus.schedule', params)
|
|
54
|
+
Models::ScheduledStatus.from_api(confirmed_schedule(response))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Slack answers a delete with ok: true whether or not anything changed —
|
|
58
|
+
# including for an id that never existed — so the response alone is not
|
|
59
|
+
# evidence. Re-reading the list at least catches an accepted delete that
|
|
60
|
+
# did not apply. It cannot distinguish "cancelled" from "was never
|
|
61
|
+
# there": both leave the id absent. `unschedule` covers that case ahead
|
|
62
|
+
# of time by finding the workspace that owns the id.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array(Symbol, String, nil)] `[:cancelled, nil]`, or
|
|
65
|
+
# `[:unconfirmed, reason]` when the delete was accepted but the
|
|
66
|
+
# following read failed. Those are different things: only the second
|
|
67
|
+
# read failed, and reporting it as a failed *cancel* would send the
|
|
68
|
+
# user back to re-cancel something already gone.
|
|
69
|
+
# @raise [ApiError] only when the status is demonstrably still there
|
|
70
|
+
def delete_scheduled(custom_status_id)
|
|
71
|
+
@api.post_form(@workspace, 'users.customStatus.deleteScheduled',
|
|
72
|
+
{ custom_status_id: custom_status_id })
|
|
73
|
+
confirm_deleted(custom_status_id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# The delete has already been accepted by the time this runs, so this
|
|
79
|
+
# only decides how much can honestly be said about it. A read that
|
|
80
|
+
# fails — including a rate-limited one, since the confirming read hits
|
|
81
|
+
# the same `list` method a multi-workspace sweep just used — is a
|
|
82
|
+
# failure to look, not a failure to delete.
|
|
83
|
+
def confirm_deleted(custom_status_id)
|
|
84
|
+
return [:cancelled, nil] unless scheduled.any? { |status| status.id == custom_status_id }
|
|
85
|
+
|
|
86
|
+
raise ApiError.new("Slack reported success but #{custom_status_id} is still scheduled.",
|
|
87
|
+
code: :delete_not_applied)
|
|
88
|
+
rescue ApiError => e
|
|
89
|
+
raise if e.code == :delete_not_applied
|
|
90
|
+
|
|
91
|
+
[:unconfirmed, e.message]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A create that reports ok without echoing back the status it made has
|
|
95
|
+
# not demonstrably created anything.
|
|
96
|
+
#
|
|
97
|
+
# The id check duplicates ScheduledStatus.validate!, deliberately:
|
|
98
|
+
# letting an id-less payload through to that guard trades this message
|
|
99
|
+
# for "Slack returned a scheduled status with no id: {}", which tells
|
|
100
|
+
# the user nothing they can act on. Here the answer is the same either
|
|
101
|
+
# way — go look at the picker.
|
|
102
|
+
def confirmed_schedule(response)
|
|
103
|
+
payload = response['scheduled_status']
|
|
104
|
+
return payload if payload.is_a?(Hash) && !payload['id'].to_s.empty?
|
|
105
|
+
|
|
106
|
+
raise ApiError.new('Slack accepted the request but returned no scheduled status, so nothing was confirmed. ' \
|
|
107
|
+
'Check the Slack status picker to see whether it was created.',
|
|
108
|
+
code: :malformed_schedule_response)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/slk/cli.rb
CHANGED
|
@@ -72,7 +72,7 @@ module Slk
|
|
|
72
72
|
else
|
|
73
73
|
show_unknown_command(command_name)
|
|
74
74
|
end
|
|
75
|
-
rescue ConfigError, EncryptionError, ApiError => e
|
|
75
|
+
rescue ConfigError, EncryptionError, ApiError, UsageError => e
|
|
76
76
|
handle_known_error(e)
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -86,7 +86,10 @@ module Slk
|
|
|
86
86
|
def handle_known_error(error)
|
|
87
87
|
label = error_label(error)
|
|
88
88
|
@output.error(label ? "#{label}: #{error.message}" : error.message)
|
|
89
|
-
|
|
89
|
+
# error.log exists to investigate faults after the fact. A mistyped flag
|
|
90
|
+
# or an unparseable time is not one, and filing a backtrace for it both
|
|
91
|
+
# buries the real entries and contradicts what UsageError promises.
|
|
92
|
+
log_error(error) unless error.is_a?(UsageError)
|
|
90
93
|
1
|
|
91
94
|
end
|
|
92
95
|
|
data/lib/slk/commands/base.rb
CHANGED
|
@@ -65,7 +65,7 @@ module Slk
|
|
|
65
65
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
66
66
|
def parse_single_option(arg, args, remaining)
|
|
67
67
|
case arg
|
|
68
|
-
when '-w', '--workspace' then @options[:workspace] = args
|
|
68
|
+
when '-w', '--workspace' then @options[:workspace] = option_value(arg, args)
|
|
69
69
|
when '--width' then parse_width_option(args)
|
|
70
70
|
when '--no-wrap' then @options[:width] = nil
|
|
71
71
|
when '--all' then @options[:all] = true
|
|
@@ -93,6 +93,18 @@ module Slk
|
|
|
93
93
|
|
|
94
94
|
protected
|
|
95
95
|
|
|
96
|
+
# A bare `args.shift` returns nil for a trailing flag, which then reads
|
|
97
|
+
# as an option nobody set: `slk status -w` selected *every* workspace
|
|
98
|
+
# and exited 0, the opposite of what it says. A following flag is the
|
|
99
|
+
# same mistake one token later — `--end --with-dnd` would take
|
|
100
|
+
# "--with-dnd" as the time.
|
|
101
|
+
def option_value(flag, args)
|
|
102
|
+
value = args.first
|
|
103
|
+
raise UsageError, "#{flag} requires a value." if value.nil? || value.start_with?('-')
|
|
104
|
+
|
|
105
|
+
args.shift
|
|
106
|
+
end
|
|
107
|
+
|
|
96
108
|
# Override in subclass to handle command-specific options
|
|
97
109
|
# Return true if option was handled, false to raise unknown option error
|
|
98
110
|
def handle_option(arg, _args, _remaining) # rubocop:disable Naming/PredicateMethod
|
data/lib/slk/commands/help.rb
CHANGED
|
@@ -74,6 +74,7 @@ module Slk
|
|
|
74
74
|
slk status Show current status
|
|
75
75
|
slk status "Working" :laptop: Set status
|
|
76
76
|
slk status clear Clear status
|
|
77
|
+
slk status schedule "Vet" 1p-3p Schedule a status (am/pm or 24h)
|
|
77
78
|
slk dnd 1h Enable DND for 1 hour
|
|
78
79
|
slk messages #general Read channel messages
|
|
79
80
|
slk preset meeting Apply preset
|
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,17 +38,17 @@ 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)
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
def handle_option(arg, args, remaining)
|
|
38
45
|
case arg
|
|
39
|
-
when '-p', '--presence'
|
|
40
|
-
|
|
41
|
-
when '-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
when '-p', '--presence' then @options[:presence] = option_value(arg, args)
|
|
47
|
+
when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
|
|
48
|
+
when '--with-dnd' then @options[:with_dnd] = true
|
|
49
|
+
when '--start' then @options[:start_at] = option_value(arg, args)
|
|
50
|
+
when '--end' then @options[:end_at] = option_value(arg, args)
|
|
51
|
+
else super
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -49,7 +56,10 @@ module Slk
|
|
|
49
56
|
help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
|
|
50
57
|
help.description('Get or set your Slack status.')
|
|
51
58
|
help.note('GET shows all workspaces by default. SET applies to primary only.')
|
|
59
|
+
help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
|
|
60
|
+
help.note('Slack allows at most 5 scheduled statuses at a time.')
|
|
52
61
|
add_examples_section(help)
|
|
62
|
+
add_scheduling_section(help)
|
|
53
63
|
add_options_section(help)
|
|
54
64
|
help.render
|
|
55
65
|
end
|
|
@@ -64,17 +74,42 @@ module Slk
|
|
|
64
74
|
end
|
|
65
75
|
end
|
|
66
76
|
|
|
77
|
+
def add_scheduling_section(help)
|
|
78
|
+
help.section('SCHEDULING') do |s|
|
|
79
|
+
s.example('slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p', 'Schedule for later')
|
|
80
|
+
s.example('slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00')
|
|
81
|
+
s.example('slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"',
|
|
82
|
+
'Span multiple days')
|
|
83
|
+
s.example('slk status schedule "Heads down" :no_bell: --start 2p', 'No end; stays until cleared')
|
|
84
|
+
s.example('slk status scheduled', 'List pending scheduled statuses')
|
|
85
|
+
s.example('slk status unschedule CS0BMQDDGWTU', 'Cancel a scheduled status')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
67
89
|
def add_options_section(help)
|
|
68
90
|
help.section('OPTIONS') do |s|
|
|
69
|
-
s
|
|
70
|
-
s
|
|
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')
|
|
91
|
+
add_general_options(s)
|
|
92
|
+
add_scheduling_options(s)
|
|
75
93
|
end
|
|
76
94
|
end
|
|
77
95
|
|
|
96
|
+
def add_general_options(section)
|
|
97
|
+
section.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
|
|
98
|
+
section.option('-d, --dnd DURATION', "Also set DND (or 'off')")
|
|
99
|
+
section.option('-w, --workspace', 'Limit to specific workspace')
|
|
100
|
+
section.option('--all', 'Set across all workspaces')
|
|
101
|
+
section.option('-v, --verbose', 'Show debug information')
|
|
102
|
+
section.option('-q, --quiet', 'Suppress output')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# These are ignored outside `schedule`, so say so rather than leaving
|
|
106
|
+
# --with-dnd looking like a sibling of -d/--dnd.
|
|
107
|
+
def add_scheduling_options(section)
|
|
108
|
+
section.option('--with-dnd', 'Scheduling only: pause notifications while the status is active')
|
|
109
|
+
section.option('--start WHEN', 'Scheduling only: window start, "[YYYY-MM-DD ]TIME"')
|
|
110
|
+
section.option('--end WHEN', 'Scheduling only: window end; omit for no expiry')
|
|
111
|
+
end
|
|
112
|
+
|
|
78
113
|
private
|
|
79
114
|
|
|
80
115
|
def get_status # rubocop:disable Naming/AccessorMethodName
|
|
@@ -210,6 +245,182 @@ module Slk
|
|
|
210
245
|
0
|
|
211
246
|
end
|
|
212
247
|
|
|
248
|
+
def schedule_status(args)
|
|
249
|
+
text, *rest = args
|
|
250
|
+
return error(SCHEDULE_USAGE) if text.to_s.strip.empty?
|
|
251
|
+
|
|
252
|
+
window = parse_schedule_window(rest)
|
|
253
|
+
return 1 unless window
|
|
254
|
+
|
|
255
|
+
result = create_scheduled_status(text, extract_emoji(rest), *window)
|
|
256
|
+
show_all_workspaces_hint
|
|
257
|
+
result
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Narrow on both axes: only the parsing is covered, so a Slack failure is
|
|
261
|
+
# not reported as a mistyped range, and only TimeFormatError is caught, so
|
|
262
|
+
# an arity or range error from this code does not surface as user error.
|
|
263
|
+
def parse_schedule_window(rest)
|
|
264
|
+
return flag_window(rest) if @options[:start_at] || @options[:end_at]
|
|
265
|
+
|
|
266
|
+
range = extract_time_range(rest)
|
|
267
|
+
return reject_window(MISSING_RANGE) unless range
|
|
268
|
+
|
|
269
|
+
Support::TimeRangeParser.parse(range)
|
|
270
|
+
rescue TimeFormatError => e
|
|
271
|
+
error(e.message)
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# --start/--end is the general form. Both times carry their own date, so
|
|
276
|
+
# it reaches multi-day windows the positional range cannot express, and
|
|
277
|
+
# omitting --end schedules a status that never auto-clears.
|
|
278
|
+
def flag_window(rest)
|
|
279
|
+
return reject_window('--end requires --start.') unless @options[:start_at]
|
|
280
|
+
return reject_window('Use either a time range or --start/--end, not both.') if extract_time_range(rest)
|
|
281
|
+
|
|
282
|
+
starts_at = Support::TimeParser.parse(@options[:start_at])
|
|
283
|
+
return [starts_at, nil] unless @options[:end_at]
|
|
284
|
+
|
|
285
|
+
[starts_at, validated_end(starts_at)]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def validated_end(starts_at)
|
|
289
|
+
ends_at = Support::TimeParser.parse(@options[:end_at])
|
|
290
|
+
# An explicit end is unambiguous, so there is nothing to roll forward.
|
|
291
|
+
return ends_at if ends_at > starts_at
|
|
292
|
+
|
|
293
|
+
raise TimeFormatError, "--end #{@options[:end_at]} is not after --start #{@options[:start_at]}."
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Returns nil so the caller reads it as "no window, already reported".
|
|
297
|
+
def reject_window(message)
|
|
298
|
+
error(message)
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The range may arrive as one token ("1:30p-3:30p") or several
|
|
303
|
+
# ("2026-08-04 13:30-15:30"), so the non-emoji arguments are matched as a
|
|
304
|
+
# whole. Emoji are dropped first because the pattern is \A-anchored and a
|
|
305
|
+
# leading ":palm_tree:" would stop the date form from matching at all.
|
|
306
|
+
#
|
|
307
|
+
# Everything left has to be part of the range. Only YYYY-MM-DD dates
|
|
308
|
+
# parse, so picking the range out and discarding "8/4" or "tomorrow"
|
|
309
|
+
# would silently schedule the status for today.
|
|
310
|
+
def extract_time_range(rest)
|
|
311
|
+
candidates = rest.reject { |arg| arg.start_with?(':') && arg.end_with?(':') }
|
|
312
|
+
return nil if candidates.empty?
|
|
313
|
+
|
|
314
|
+
joined = candidates.join(' ')
|
|
315
|
+
return joined if Support::TimeRangeParser.match?(joined)
|
|
316
|
+
|
|
317
|
+
raise TimeFormatError, "Unrecognized argument: #{joined}. Use #{Support::TimeRangeParser::EXAMPLE}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Each workspace is an independent call, so one failure must not strand
|
|
321
|
+
# the workspaces after it: the user would re-run to fix the tail and
|
|
322
|
+
# double-schedule everything that already succeeded.
|
|
323
|
+
def each_workspace_reporting(workspaces)
|
|
324
|
+
failed = false
|
|
325
|
+
|
|
326
|
+
workspaces.each do |workspace|
|
|
327
|
+
yield workspace
|
|
328
|
+
rescue ApiError => e
|
|
329
|
+
failed = true
|
|
330
|
+
error("#{workspace.name}: #{e.message}")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
failed ? 1 : 0
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def create_scheduled_status(text, emoji, starts_at, ends_at)
|
|
337
|
+
each_workspace_reporting(target_workspaces) do |workspace|
|
|
338
|
+
scheduled = runner.custom_status_api(workspace.name).schedule(
|
|
339
|
+
text: text, emoji: emoji,
|
|
340
|
+
date_scheduled: starts_at, date_expire: ends_at,
|
|
341
|
+
dnd: @options[:with_dnd]
|
|
342
|
+
)
|
|
343
|
+
success("Scheduled on #{workspace.name}: #{scheduled}")
|
|
344
|
+
debug(" ID: #{scheduled.id}")
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def list_scheduled
|
|
349
|
+
workspaces = target_workspaces_for_get
|
|
350
|
+
|
|
351
|
+
each_workspace_reporting(workspaces) do |workspace|
|
|
352
|
+
puts output.bold(workspace.name) if workspaces.size > 1
|
|
353
|
+
print_scheduled(runner.custom_status_api(workspace.name).scheduled)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def print_scheduled(scheduled)
|
|
358
|
+
return puts ' (none scheduled)' if scheduled.empty?
|
|
359
|
+
|
|
360
|
+
scheduled.each { |status| puts " #{status.id} #{status}" }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def unschedule_status(args)
|
|
364
|
+
id = args.first
|
|
365
|
+
return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
|
|
366
|
+
|
|
367
|
+
@unchecked = []
|
|
368
|
+
targets = unschedule_targets(id)
|
|
369
|
+
return report_id_not_found(id) if targets.empty?
|
|
370
|
+
|
|
371
|
+
each_workspace_reporting(targets) do |workspace|
|
|
372
|
+
outcome, reason = runner.custom_status_api(workspace.name).delete_scheduled(id)
|
|
373
|
+
report_cancelled(workspace, outcome, reason)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# The delete succeeded either way, so both are successes — but only one
|
|
378
|
+
# of them has been checked, and saying so is the difference between the
|
|
379
|
+
# user moving on and the user re-cancelling something already gone.
|
|
380
|
+
def report_cancelled(workspace, outcome, reason)
|
|
381
|
+
return success("Cancelled scheduled status on #{workspace.name}") if outcome == :cancelled
|
|
382
|
+
|
|
383
|
+
warn("Cancelled scheduled status on #{workspace.name}, but could not confirm it: #{reason}")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# `slk status scheduled` lists every workspace, so the obvious next step
|
|
387
|
+
# is to paste an ID straight back in. Defaulting to the primary workspace
|
|
388
|
+
# made that fail with a bare Slack error whenever the ID came from
|
|
389
|
+
# another one, so look up the owner instead. An explicit -w/--all still
|
|
390
|
+
# wins, and a lone workspace needs no lookup.
|
|
391
|
+
def unschedule_targets(id)
|
|
392
|
+
return target_workspaces if @options[:all] || @options[:workspace]
|
|
393
|
+
|
|
394
|
+
workspaces = runner.all_workspaces
|
|
395
|
+
return workspaces if workspaces.size <= 1
|
|
396
|
+
|
|
397
|
+
[workspaces.find { |workspace| owns_scheduled?(workspace, id) }].compact
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# "Not found" is only true of the workspaces we actually reached. Saying
|
|
401
|
+
# it flatly after warning that one could not be checked contradicts the
|
|
402
|
+
# warning, and pointing at `slk status scheduled` would just fail the
|
|
403
|
+
# same way — it calls the same endpoint.
|
|
404
|
+
def report_id_not_found(id)
|
|
405
|
+
return error("No scheduled status #{id} found. Run 'slk status scheduled' to list IDs.") if @unchecked.empty?
|
|
406
|
+
|
|
407
|
+
error("#{id} was not found, but #{@unchecked.join(', ')} could not be checked. " \
|
|
408
|
+
'Retry, or name the workspace with -w to cancel it directly.')
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def owns_scheduled?(workspace, id)
|
|
412
|
+
runner.custom_status_api(workspace.name).scheduled.any? { |status| status.id == id }
|
|
413
|
+
rescue RateLimitError
|
|
414
|
+
# Every remaining check spends another call against the same limit, so
|
|
415
|
+
# continuing turns one rate limit into several and still cannot answer.
|
|
416
|
+
raise
|
|
417
|
+
rescue ApiError => e
|
|
418
|
+
# Skipping it silently would report "not found" for an ID that exists.
|
|
419
|
+
warn("Could not check #{workspace.name}: #{e.message}")
|
|
420
|
+
@unchecked << workspace.name
|
|
421
|
+
false
|
|
422
|
+
end
|
|
423
|
+
|
|
213
424
|
def show_all_workspaces_hint
|
|
214
425
|
# Show hint if user has multiple workspaces and didn't use --all or -w
|
|
215
426
|
return if @options[:all] || @options[:workspace]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# A status queued to turn on later, from users.customStatus.list.
|
|
6
|
+
#
|
|
7
|
+
# Slack derives `duration` server-side from the window, so it is not
|
|
8
|
+
# modelled here — the window itself is the source of truth.
|
|
9
|
+
ScheduledStatus = Data.define(:id, :text, :emoji, :date_scheduled, :date_expire, :dnd, :active) do
|
|
10
|
+
def self.from_api(data)
|
|
11
|
+
validate!(data)
|
|
12
|
+
|
|
13
|
+
new(
|
|
14
|
+
id: data['id'].to_s,
|
|
15
|
+
text: data['text'].to_s,
|
|
16
|
+
emoji: data['emoji'].to_s,
|
|
17
|
+
date_scheduled: data['date_scheduled'].to_i,
|
|
18
|
+
date_expire: data['date_expire'].to_i,
|
|
19
|
+
dnd: truthy?(data['is_dnd']),
|
|
20
|
+
active: truthy?(data['is_active'])
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every field is coerced, so without this guard any payload at all —
|
|
25
|
+
# a bare string, nil, a hash of unexpected keys — becomes a valid-looking
|
|
26
|
+
# record with an empty id that prints as a blank line and never matches
|
|
27
|
+
# the id the user asked about. An unusable record is a protocol change,
|
|
28
|
+
# not a status.
|
|
29
|
+
def self.validate!(data)
|
|
30
|
+
return if data.is_a?(Hash) && !data['id'].to_s.empty?
|
|
31
|
+
|
|
32
|
+
raise ApiError.new("Slack returned a scheduled status with no id: #{data.inspect[0, 120]}",
|
|
33
|
+
code: :malformed_scheduled_status)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :validate!
|
|
36
|
+
|
|
37
|
+
# These endpoints are string-typed on the way in — Api::CustomStatus sends
|
|
38
|
+
# is_dnd as 'true' — so a strict `== true` would quietly read a scheduled
|
|
39
|
+
# DND back as off. Accept the shapes Slack actually uses.
|
|
40
|
+
def self.truthy?(value) = [true, 'true', 1, '1'].include?(value)
|
|
41
|
+
private_class_method :truthy?
|
|
42
|
+
|
|
43
|
+
def starts_at = date_scheduled.positive? ? Time.at(date_scheduled) : nil
|
|
44
|
+
def ends_at = date_expire.positive? ? Time.at(date_expire) : nil
|
|
45
|
+
|
|
46
|
+
def to_s
|
|
47
|
+
span = window
|
|
48
|
+
[
|
|
49
|
+
emoji,
|
|
50
|
+
text,
|
|
51
|
+
span.empty? ? '' : "(#{span})",
|
|
52
|
+
('[dnd]' if dnd),
|
|
53
|
+
# Slack marks the one that has already turned on. Worth showing: it
|
|
54
|
+
# is the difference between "will happen" and "is happening".
|
|
55
|
+
('[active]' if active)
|
|
56
|
+
].reject { |part| part.to_s.empty? }.join(' ')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Human-readable window; the end date is only repeated when it differs
|
|
60
|
+
# from the start date, which makes multi-day windows obvious.
|
|
61
|
+
def window
|
|
62
|
+
dated = '%a %b %-d %-l:%M%P'
|
|
63
|
+
start_time = starts_at or return ''
|
|
64
|
+
formatted = start_time.strftime(dated)
|
|
65
|
+
finish = ends_at or return formatted
|
|
66
|
+
|
|
67
|
+
"#{formatted} -> #{finish.strftime(same_day?(start_time, finish) ? '%-l:%M%P' : dated)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def same_day?(start_time, finish)
|
|
73
|
+
start_time.to_date == finish.to_date
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/slk/runner.rb
CHANGED
|
@@ -88,6 +88,10 @@ module Slk
|
|
|
88
88
|
Api::Saved.new(@api_client, workspace(workspace_name))
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def custom_status_api(workspace_name = nil)
|
|
92
|
+
Api::CustomStatus.new(@api_client, workspace(workspace_name))
|
|
93
|
+
end
|
|
94
|
+
|
|
91
95
|
def team_api(workspace_name = nil)
|
|
92
96
|
Api::Team.new(@api_client, workspace(workspace_name))
|
|
93
97
|
end
|
|
@@ -104,8 +104,15 @@ module Slk
|
|
|
104
104
|
'Please provide the correct public key for this private key.'
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# `-P ''` supplies the passphrase up front so ssh-keygen cannot ask for
|
|
108
|
+
# one. Without it, an encrypted key makes ssh-keygen prompt — and it
|
|
109
|
+
# prompts on the console directly, not on the stdin capture3 hands it, so
|
|
110
|
+
# closing stdin does not help. On Windows that read blocks forever; this
|
|
111
|
+
# hung the CI job until its timeout with no output. An empty passphrase
|
|
112
|
+
# fails an encrypted key with the same message it already reported, which
|
|
113
|
+
# is the right answer regardless: slk cannot use such a key anyway.
|
|
107
114
|
def derive_public_key(private_key_path)
|
|
108
|
-
output, error, status = Open3.capture3('ssh-keygen', '-y', '-f', private_key_path)
|
|
115
|
+
output, error, status = Open3.capture3('ssh-keygen', '-y', '-P', '', '-f', private_key_path)
|
|
109
116
|
return output.strip if status.success?
|
|
110
117
|
|
|
111
118
|
# Check if ssh-keygen is missing vs other failures
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module Slk
|
|
7
|
+
module Support
|
|
8
|
+
# Resolves a single clock time ("1:30p", "2026-08-12 8a") to a Unix
|
|
9
|
+
# timestamp. A bare time at or before now rolls to tomorrow; an explicit
|
|
10
|
+
# YYYY-MM-DD date is honoured as given.
|
|
11
|
+
#
|
|
12
|
+
# TimeRangeParser builds the two-sided form on the clock arithmetic here.
|
|
13
|
+
class TimeParser
|
|
14
|
+
TIME = /(\d{1,2})(?::(\d{2}))?\s*([ap]m?)?/i
|
|
15
|
+
PATTERN = /\A(?:(\d{4}-\d{2}-\d{2})\s+)?#{TIME}\z/i
|
|
16
|
+
|
|
17
|
+
EXAMPLE = '1:30p or 2026-08-12 8:00'
|
|
18
|
+
|
|
19
|
+
MINUTES_PER_DAY = 24 * 60
|
|
20
|
+
|
|
21
|
+
# A bare hour of 0 or 13-23 can only be 24-hour notation, so no am/pm was
|
|
22
|
+
# omitted. Anything in 1..12 is genuinely ambiguous without one.
|
|
23
|
+
CLOCK_HOURS = (1..12)
|
|
24
|
+
|
|
25
|
+
# @param now [Time] reference point for rolling bare times forward
|
|
26
|
+
# @return [Integer] Unix timestamp
|
|
27
|
+
def self.parse(input, now: Time.now) = new(now: now).parse(input)
|
|
28
|
+
|
|
29
|
+
def initialize(now: Time.now)
|
|
30
|
+
@now = now
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse(input)
|
|
34
|
+
match = PATTERN.match(input.to_s.strip)
|
|
35
|
+
raise TimeFormatError, "Invalid time: #{input}. Use #{EXAMPLE}" unless match
|
|
36
|
+
|
|
37
|
+
parts = match.captures[1..3]
|
|
38
|
+
date = match[1] ? parse_date(match[1]) : roll_forward(parts)
|
|
39
|
+
at(date, *parts).to_i
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The rest of this class is clock arithmetic shared with TimeRangeParser,
|
|
43
|
+
# which needs to place the same parts on dates it works out itself.
|
|
44
|
+
|
|
45
|
+
# @param example [String] format hint, so a caller with its own syntax
|
|
46
|
+
# (TimeRangeParser) does not advertise this class's example
|
|
47
|
+
def parse_date(text, example: EXAMPLE)
|
|
48
|
+
Date.parse(text)
|
|
49
|
+
rescue Date::Error
|
|
50
|
+
raise TimeFormatError, "Invalid date: #{text}. Use #{example}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def at(date, *parts)
|
|
54
|
+
hours, minutes = to_24_hour(*parts)
|
|
55
|
+
time = build(date, hours, minutes)
|
|
56
|
+
# Ruby silently shifts a local time that DST skips (2:30a on a
|
|
57
|
+
# spring-forward date becomes 3:30a), which would move the window
|
|
58
|
+
# rather than fail. Reject it instead.
|
|
59
|
+
return time if time.hour == hours && time.min == minutes
|
|
60
|
+
|
|
61
|
+
raise TimeFormatError,
|
|
62
|
+
format('%<clock>s does not exist on %<date>s (clocks skip forward for DST).',
|
|
63
|
+
clock: format('%<h>02d:%<m>02d', h: hours, m: minutes), date: date)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Same placement without the existence check, for callers asking a
|
|
67
|
+
# question a nonexistent time still answers. `at` would raise, which for
|
|
68
|
+
# the roll-forward probe would reject "2:30a" outright on a spring-forward
|
|
69
|
+
# date instead of rolling it to the next day, where it does exist.
|
|
70
|
+
def place(date, *parts) = build(date, *to_24_hour(*parts))
|
|
71
|
+
|
|
72
|
+
# Minutes since midnight, for comparing two times before either has a date.
|
|
73
|
+
def minutes(parts)
|
|
74
|
+
hours, mins = to_24_hour(*parts)
|
|
75
|
+
(hours * 60) + mins
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# True when these parts could have meant either am or pm — no meridiem
|
|
79
|
+
# given, and an hour small enough that both readings are plausible.
|
|
80
|
+
def ambiguous?(parts) = parts[2].nil? && CLOCK_HOURS.cover?(parts[0].to_i)
|
|
81
|
+
|
|
82
|
+
# The opposite: a bare hour too large to be a 12-hour clock reading, so
|
|
83
|
+
# the writer was plainly using 24-hour notation. TimeRangeParser treats
|
|
84
|
+
# one of these as settling how to read the *other* side of a range.
|
|
85
|
+
def twenty_four_hour?(parts) = parts[2].nil? && !CLOCK_HOURS.cover?(parts[0].to_i)
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def build(date, hours, minutes) = Time.new(date.year, date.month, date.day, hours, minutes, 0)
|
|
90
|
+
|
|
91
|
+
def roll_forward(parts)
|
|
92
|
+
today = @now.to_date
|
|
93
|
+
place(today, *parts) <= @now ? today + 1 : today
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_24_hour(hour, minute, meridiem)
|
|
97
|
+
hours = hour.to_i
|
|
98
|
+
minutes = minute.to_i
|
|
99
|
+
raise TimeFormatError, "Invalid minute: #{minute}" if minutes > 59
|
|
100
|
+
return [validate_24_hour(hours), minutes] unless meridiem
|
|
101
|
+
|
|
102
|
+
[apply_meridiem(hours, meridiem), minutes]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_24_hour(hours)
|
|
106
|
+
raise TimeFormatError, "Invalid hour: #{hours}" if hours > 23
|
|
107
|
+
|
|
108
|
+
hours
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def apply_meridiem(hours, meridiem)
|
|
112
|
+
raise TimeFormatError, "Invalid hour for 12-hour time: #{hours}" if hours.zero? || hours > 12
|
|
113
|
+
|
|
114
|
+
# 12am is hour 0 and 12pm is hour 12, so fold 12 down before shifting.
|
|
115
|
+
base = hours % 12
|
|
116
|
+
meridiem[0].casecmp?('p') ? base + 12 : base
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module Slk
|
|
7
|
+
module Support
|
|
8
|
+
# Parses a scheduling window ("1:30p-3:30p", "2026-08-04 13:30-15:30")
|
|
9
|
+
# into a [start, end] pair of Unix timestamps.
|
|
10
|
+
#
|
|
11
|
+
# Unlike DateParser, which resolves an input to a point in the past, this
|
|
12
|
+
# always resolves forward: a bare time at or before now rolls to tomorrow,
|
|
13
|
+
# and an end before the start rolls to the next day so overnight windows
|
|
14
|
+
# ("11p-1a") work.
|
|
15
|
+
#
|
|
16
|
+
# The start date is written once and the end can only reach the following
|
|
17
|
+
# day, so this cannot express a multi-day window — `slk status schedule
|
|
18
|
+
# --start/--end` is the general form for that.
|
|
19
|
+
class TimeRangeParser
|
|
20
|
+
RANGE_PATTERN = /\A(?:(\d{4}-\d{2}-\d{2})\s+)?#{TimeParser::TIME}\s*-\s*#{TimeParser::TIME}\z/i
|
|
21
|
+
|
|
22
|
+
EXAMPLE = '1:30p-3:30p or 2026-08-04 13:30-15:30'
|
|
23
|
+
|
|
24
|
+
# Longest overnight window accepted when a reading had to be guessed.
|
|
25
|
+
# Twelve hours is not a policy about window length — an explicit
|
|
26
|
+
# "8p-9a" is longer and fine — it is the point past which a guessed
|
|
27
|
+
# crossing can no longer be what the user meant.
|
|
28
|
+
MAX_GUESSED_OVERNIGHT_MINUTES = 12 * 60
|
|
29
|
+
|
|
30
|
+
# @param input [String] the range to parse
|
|
31
|
+
# @param now [Time] reference point for rolling bare times forward
|
|
32
|
+
# @return [Array(Integer, Integer)] start and end Unix timestamps
|
|
33
|
+
def self.parse(input, now: Time.now) = new(now: now).parse(input)
|
|
34
|
+
|
|
35
|
+
# True when input looks like a time range, used to pick it out of argv.
|
|
36
|
+
def self.match?(input) = RANGE_PATTERN.match?(input.to_s.strip)
|
|
37
|
+
|
|
38
|
+
def initialize(now: Time.now)
|
|
39
|
+
@now = now
|
|
40
|
+
@clock = TimeParser.new(now: now)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse(input)
|
|
44
|
+
match = RANGE_PATTERN.match(input.to_s.strip)
|
|
45
|
+
raise TimeFormatError, "Invalid time range: #{input}. Use #{EXAMPLE}" unless match
|
|
46
|
+
|
|
47
|
+
start_parts, end_parts = infer_meridiems(match)
|
|
48
|
+
validate_range(input, start_parts, end_parts)
|
|
49
|
+
|
|
50
|
+
date = start_date(match, start_parts)
|
|
51
|
+
start_at = @clock.at(date, *start_parts)
|
|
52
|
+
|
|
53
|
+
[start_at.to_i, end_time(date, start_at, end_parts).to_i]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# `match.captures` layout: 0 is the optional date, 1..3 the start time,
|
|
59
|
+
# 4..6 the end. Note the date is `match[1]` in the 1-indexed MatchData form.
|
|
60
|
+
#
|
|
61
|
+
# "1-3p" means 1pm, not 1am: when only one side names a meridiem the
|
|
62
|
+
# other borrows it. The borrow is skipped when it would invert the range
|
|
63
|
+
# ("9-5p" is 9am to 5pm, not 9pm to 5pm).
|
|
64
|
+
def infer_meridiems(match)
|
|
65
|
+
start_parts = match.captures[1..3]
|
|
66
|
+
end_parts = match.captures[4..6]
|
|
67
|
+
|
|
68
|
+
if borrowable?(start_parts, end_parts)
|
|
69
|
+
borrowed = with_meridiem(start_parts, end_parts[2])
|
|
70
|
+
return [borrowed, end_parts] if @clock.minutes(borrowed) < @clock.minutes(end_parts)
|
|
71
|
+
elsif borrowable?(end_parts, start_parts)
|
|
72
|
+
borrowed = with_meridiem(end_parts, start_parts[2])
|
|
73
|
+
return [start_parts, borrowed] if @clock.minutes(start_parts) < @clock.minutes(borrowed)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
[start_parts, end_parts]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def borrowable?(parts, source) = !source[2].nil? && @clock.ambiguous?(parts)
|
|
80
|
+
|
|
81
|
+
def with_meridiem(parts, meridiem) = [parts[0], parts[1], meridiem]
|
|
82
|
+
|
|
83
|
+
# An explicit date is honoured as given; a bare time at or before now
|
|
84
|
+
# refers to tomorrow.
|
|
85
|
+
def start_date(match, start_parts)
|
|
86
|
+
return @clock.parse_date(match[1], example: EXAMPLE) if match[1]
|
|
87
|
+
|
|
88
|
+
today = @now.to_date
|
|
89
|
+
# `place`, not `at`: this only asks "is that time already past today?",
|
|
90
|
+
# and on a spring-forward date `at` would reject a skipped time outright
|
|
91
|
+
# instead of letting it roll to tomorrow, where it exists.
|
|
92
|
+
@clock.place(today, *start_parts) <= @now ? today + 1 : today
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def end_time(date, start_at, end_parts)
|
|
96
|
+
end_at = @clock.at(date, *end_parts)
|
|
97
|
+
# An end before the start means the window crosses midnight.
|
|
98
|
+
end_at < start_at ? @clock.at(date + 1, *end_parts) : end_at
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_range(input, start_parts, end_parts)
|
|
102
|
+
if @clock.minutes(start_parts) == @clock.minutes(end_parts)
|
|
103
|
+
raise TimeFormatError, "Time range #{input} starts and ends at the same time."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
validate_overnight(input, start_parts, end_parts)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# "9-5" almost always means 9am-5pm, but reads literally as 09:00 to
|
|
110
|
+
# 05:00 the next morning — a 20-hour window nobody asked for. So is
|
|
111
|
+
# "9a-5", where the am/pm is there but on the wrong side to help.
|
|
112
|
+
#
|
|
113
|
+
# Only guessed readings are checked, and only past 12 hours, which is
|
|
114
|
+
# what separates the mistake from the intent. An overnight shift written
|
|
115
|
+
# with a pm start ("9p-5", "10p-2") can only come out 12 hours or less;
|
|
116
|
+
# a dropped meridiem ("9-5", "9a-5", "10-5a") can only come out longer.
|
|
117
|
+
def validate_overnight(input, start_parts, end_parts)
|
|
118
|
+
return unless guessed?(start_parts, end_parts)
|
|
119
|
+
|
|
120
|
+
span = overnight_minutes(start_parts, end_parts)
|
|
121
|
+
return if span.nil? || span <= MAX_GUESSED_OVERNIGHT_MINUTES
|
|
122
|
+
|
|
123
|
+
raise TimeFormatError,
|
|
124
|
+
"Time range #{input} reads as crossing midnight and spans #{format_span(span)}. " \
|
|
125
|
+
'Add am/pm to both sides (9a-5p), use 24-hour times (9:00-17:00), ' \
|
|
126
|
+
'or --start/--end for a multi-day window.'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# True when a side's reading had to be guessed.
|
|
130
|
+
#
|
|
131
|
+
# A leftover ambiguous side is not enough on its own: in "20:00-6" the
|
|
132
|
+
# 24-hour start settles the whole range, so "6" is 6am and was not
|
|
133
|
+
# guessed. Without such an anchor, any side still bare after
|
|
134
|
+
# `infer_meridiems` was read as 24-hour for want of anything better —
|
|
135
|
+
# either because no meridiem was given at all ("9-5"), or because
|
|
136
|
+
# borrowing the one that was given would have inverted the range
|
|
137
|
+
# ("9a-5", where 5am precedes 9am).
|
|
138
|
+
def guessed?(start_parts, end_parts)
|
|
139
|
+
return false if @clock.twenty_four_hour?(start_parts) || @clock.twenty_four_hour?(end_parts)
|
|
140
|
+
|
|
141
|
+
@clock.ambiguous?(start_parts) || @clock.ambiguous?(end_parts)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Wall-clock minutes from start to end across midnight, or nil when the
|
|
145
|
+
# window stays on one date.
|
|
146
|
+
#
|
|
147
|
+
# Deliberately not `end_at - start_at`: elapsed seconds stretch or shrink
|
|
148
|
+
# by an hour across a DST boundary, so the same text would report a
|
|
149
|
+
# different span depending on the date it happened to land on.
|
|
150
|
+
def overnight_minutes(start_parts, end_parts)
|
|
151
|
+
start_minutes = @clock.minutes(start_parts)
|
|
152
|
+
end_minutes = @clock.minutes(end_parts)
|
|
153
|
+
return nil if end_minutes > start_minutes
|
|
154
|
+
|
|
155
|
+
TimeParser::MINUTES_PER_DAY - start_minutes + end_minutes
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Whole hours read better, but rounding them would report a 12h01m window
|
|
159
|
+
# as "12 hours" against a 12-hour limit.
|
|
160
|
+
def format_span(minutes)
|
|
161
|
+
hours, mins = minutes.divmod(60)
|
|
162
|
+
mins.zero? ? "#{hours} hours" : format('%<h>dh%<m>02dm', h: hours, m: mins)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -12,6 +12,17 @@ require 'io/console'
|
|
|
12
12
|
module Slk
|
|
13
13
|
class Error < StandardError; end
|
|
14
14
|
|
|
15
|
+
# The invocation itself was wrong (a flag with no value, an unparseable
|
|
16
|
+
# time). The message is written for the user and is printed verbatim, with
|
|
17
|
+
# no "Error type:" label, because there is no internal fault to report.
|
|
18
|
+
class UsageError < Error; end
|
|
19
|
+
|
|
20
|
+
# A time or range the user typed that cannot be understood. Separate from
|
|
21
|
+
# UsageError so callers can rescue *only* malformed input: a blanket
|
|
22
|
+
# `rescue ArgumentError` around a parse call also swallows arity and range
|
|
23
|
+
# errors from the code itself and presents them as if the user mistyped.
|
|
24
|
+
class TimeFormatError < UsageError; end
|
|
25
|
+
|
|
15
26
|
# Errors from any Slack-API-shaped failure: HTTP errors, network errors,
|
|
16
27
|
# logical Slack errors (user_not_found, missing_scope, etc.), JSON parse
|
|
17
28
|
# failures. The optional `code` symbol lets callers match specific cases
|
|
@@ -55,6 +66,7 @@ module Slk
|
|
|
55
66
|
autoload :Duration, 'slk/models/duration'
|
|
56
67
|
autoload :Workspace, 'slk/models/workspace'
|
|
57
68
|
autoload :Status, 'slk/models/status'
|
|
69
|
+
autoload :ScheduledStatus, 'slk/models/scheduled_status'
|
|
58
70
|
autoload :Message, 'slk/models/message'
|
|
59
71
|
autoload :Reaction, 'slk/models/reaction'
|
|
60
72
|
autoload :User, 'slk/models/user'
|
|
@@ -153,6 +165,7 @@ module Slk
|
|
|
153
165
|
autoload :Activity, 'slk/api/activity'
|
|
154
166
|
autoload :Search, 'slk/api/search'
|
|
155
167
|
autoload :Saved, 'slk/api/saved'
|
|
168
|
+
autoload :CustomStatus, 'slk/api/custom_status'
|
|
156
169
|
autoload :Team, 'slk/api/team'
|
|
157
170
|
end
|
|
158
171
|
|
|
@@ -167,5 +180,7 @@ module Slk
|
|
|
167
180
|
autoload :TextWrapper, 'slk/support/text_wrapper'
|
|
168
181
|
autoload :InteractivePrompt, 'slk/support/interactive_prompt'
|
|
169
182
|
autoload :DateParser, 'slk/support/date_parser'
|
|
183
|
+
autoload :TimeParser, 'slk/support/time_parser'
|
|
184
|
+
autoload :TimeRangeParser, 'slk/support/time_range_parser'
|
|
170
185
|
end
|
|
171
186
|
end
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -30,6 +30,7 @@ files:
|
|
|
30
30
|
- lib/slk/api/bots.rb
|
|
31
31
|
- lib/slk/api/client.rb
|
|
32
32
|
- lib/slk/api/conversations.rb
|
|
33
|
+
- lib/slk/api/custom_status.rb
|
|
33
34
|
- lib/slk/api/dnd.rb
|
|
34
35
|
- lib/slk/api/emoji.rb
|
|
35
36
|
- lib/slk/api/saved.rb
|
|
@@ -85,6 +86,7 @@ files:
|
|
|
85
86
|
- lib/slk/models/profile_field.rb
|
|
86
87
|
- lib/slk/models/reaction.rb
|
|
87
88
|
- lib/slk/models/saved_item.rb
|
|
89
|
+
- lib/slk/models/scheduled_status.rb
|
|
88
90
|
- lib/slk/models/search_result.rb
|
|
89
91
|
- lib/slk/models/status.rb
|
|
90
92
|
- lib/slk/models/user.rb
|
|
@@ -123,6 +125,8 @@ files:
|
|
|
123
125
|
- lib/slk/support/platform.rb
|
|
124
126
|
- lib/slk/support/slack_url_parser.rb
|
|
125
127
|
- lib/slk/support/text_wrapper.rb
|
|
128
|
+
- lib/slk/support/time_parser.rb
|
|
129
|
+
- lib/slk/support/time_range_parser.rb
|
|
126
130
|
- lib/slk/support/user_resolver.rb
|
|
127
131
|
- lib/slk/support/xdg_paths.rb
|
|
128
132
|
- lib/slk/version.rb
|
|
@@ -148,7 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
148
152
|
- !ruby/object:Gem::Version
|
|
149
153
|
version: '0'
|
|
150
154
|
requirements: []
|
|
151
|
-
rubygems_version: 4.0.
|
|
155
|
+
rubygems_version: 4.0.13
|
|
152
156
|
specification_version: 4
|
|
153
157
|
summary: A command-line interface for Slack
|
|
154
158
|
test_files: []
|