slk 0.5.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 +83 -1
- data/README.md +31 -0
- data/lib/slk/api/custom_status.rb +112 -0
- data/lib/slk/api/team.rb +24 -0
- data/lib/slk/api/users.rb +10 -0
- data/lib/slk/cli.rb +14 -3
- data/lib/slk/commands/base.rb +13 -1
- data/lib/slk/commands/debug.rb +110 -0
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/org.rb +119 -0
- data/lib/slk/commands/status.rb +224 -13
- data/lib/slk/commands/who.rb +115 -0
- data/lib/slk/formatters/output.rb +2 -0
- data/lib/slk/formatters/profile_field_renderer.rb +107 -0
- data/lib/slk/formatters/profile_formatter.rb +87 -0
- data/lib/slk/formatters/profile_rows.rb +72 -0
- data/lib/slk/models/profile.rb +71 -0
- data/lib/slk/models/profile_field.rb +29 -0
- data/lib/slk/models/scheduled_status.rb +77 -0
- data/lib/slk/runner.rb +21 -0
- data/lib/slk/services/api_client.rb +75 -10
- data/lib/slk/services/cache_store.rb +46 -2
- data/lib/slk/services/encryption.rb +8 -1
- data/lib/slk/services/meta_cache.rb +32 -0
- data/lib/slk/services/profile_builder.rb +129 -0
- data/lib/slk/services/profile_resolver.rb +138 -0
- data/lib/slk/services/user_lookup.rb +9 -13
- data/lib/slk/services/user_matcher.rb +64 -0
- data/lib/slk/services/user_picker.rb +68 -0
- data/lib/slk/services/who_target_resolver.rb +64 -0
- 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 +59 -1
- metadata +21 -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,86 @@ 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
|
|
36
|
+
|
|
37
|
+
## [0.6.0] - 2026-04-27
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **`slk who [target]`** — compact teems-style profile card for self or any user
|
|
42
|
+
- Targets: positional arg accepts `Uxxx`, display name, real name, or email; defaults to self
|
|
43
|
+
- `--full` expands into Contact / People / About me sections matching the Slack web profile
|
|
44
|
+
- `--json` emits the full Profile struct for piping
|
|
45
|
+
- `--refresh` bypasses both the per-run memo and the on-disk meta cache
|
|
46
|
+
- `--all` prints every match in turn; `--pick N` picks the Nth match non-interactively
|
|
47
|
+
- Multi-match disambiguation: prints a numbered list on stderr and prompts on a TTY; non-TTY contexts raise instead of silently picking
|
|
48
|
+
- Renders Slack Connect external users with a stripped layout (`external — <home workspace>`)
|
|
49
|
+
- Marks deactivated accounts with a bold `deactivated account` tag (and `deactivated: true` in JSON)
|
|
50
|
+
- Type-aware custom field rendering: `link` fields use OSC 8 hyperlinks with their `alt` label, `date` fields show "Jun 17, 2024 (1y 10mo ago)", `user` fields resolve one level (name + pronouns + title)
|
|
51
|
+
- **`slk org [target]`** — walks the supervisor chain upward from the target
|
|
52
|
+
- `--depth N` caps traversal (default 5); cycle-safe via seen-set
|
|
53
|
+
- Indented tree with `└─ ├─ │` glyphs; `← you` marker on whichever node is the authenticated user
|
|
54
|
+
- New `Api::Team` wrapper for `team.info` and `team.profile.get`, plus `Api::Users#profile_for(user_id, include_labels:)`
|
|
55
|
+
- `Services::ProfileResolver`, `ProfileBuilder`, `UserMatcher`, `UserPicker`, `WhoTargetResolver`, `MetaCache` services backing the new commands
|
|
56
|
+
- Hidden `slk debug profile <uid>` subcommand for inspecting raw profile/info/schema responses
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- `ApiError` now carries a typed `code` symbol (`:user_not_found`, `:ratelimited`, `:network_error`, `:unauthorized`, `:http_error`, `:invalid_json`, `:missing_scope`); `ApiClient` populates it on every raise
|
|
61
|
+
- `ProfileResolver` only swallows `:user_not_found` from `users.profile.get` (Slack Connect fallback to `users.info`); other API errors propagate so callers can surface them
|
|
62
|
+
- CI matrix now uses `bundler-cache` and runs `bundle exec rake test`; new `coverage` job enforces a 95/95 line/branch SimpleCov threshold
|
|
63
|
+
- Pinned `parallel < 2.0` in dev/test bundle to keep Ruby 3.2 compatible with current rubocop
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- `slk org` no longer mislabels the wrong node with `← you` when invoked against a teammate — the marker now compares each node against the authenticated user id
|
|
68
|
+
|
|
69
|
+
## [0.5.0] - 2026-04-13
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- **`--fetch-attachments` flag** - Download message files and attachment images to local cache
|
|
74
|
+
- Downloads Slack files (authed) and public attachment images (Giphy, Tenor, etc.)
|
|
75
|
+
- Cached to `~/.cache/slk/files/{workspace}/` with skip-on-rerun
|
|
76
|
+
- Shows copyable local file paths in output: `[File: /path/to/file.png]`
|
|
77
|
+
- Works with `messages`, `thread`, and `--threads` inline replies
|
|
78
|
+
- Summary line when files are present: `9 files not downloaded. Use --fetch-attachments to download.`
|
|
79
|
+
- Follows up to 3 redirect hops with relative URL resolution
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
|
|
83
|
+
- Added `rubocop` as a dev dependency for linting
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- **`thread` command** - Extracted `resolve_and_display_thread` to fix rubocop complexity warnings
|
|
9
88
|
|
|
10
89
|
## [0.4.2] - 2026-03-01
|
|
11
90
|
|
|
@@ -131,6 +210,9 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
|
|
|
131
210
|
- Pure Ruby stdlib - no gem dependencies
|
|
132
211
|
- Ruby 3.2+ with modern features (Data.define, pattern matching)
|
|
133
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
|
|
215
|
+
[0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
|
|
134
216
|
[0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
|
|
135
217
|
[0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
|
|
136
218
|
[0.3.0]: https://github.com/ericboehs/slk/releases/tag/v0.3.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/api/team.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Api
|
|
5
|
+
# Wrapper for Slack team.* API endpoints
|
|
6
|
+
class Team
|
|
7
|
+
def initialize(api_client, workspace)
|
|
8
|
+
@api = api_client
|
|
9
|
+
@workspace = workspace
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def info(team_id = nil)
|
|
13
|
+
params = team_id ? { team: team_id } : {}
|
|
14
|
+
@api.post_form(@workspace, 'team.info', params)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def profile_schema(visibility: nil)
|
|
18
|
+
params = {}
|
|
19
|
+
params[:visibility] = visibility if visibility
|
|
20
|
+
@api.post_form(@workspace, 'team.profile.get', params)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/slk/api/users.rb
CHANGED
|
@@ -49,6 +49,10 @@ module Slk
|
|
|
49
49
|
}
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
def get_presence_for(user_id)
|
|
53
|
+
@api.post_form(@workspace, 'users.getPresence', { user: user_id })
|
|
54
|
+
end
|
|
55
|
+
|
|
52
56
|
def set_presence(presence) # rubocop:disable Naming/AccessorMethodName
|
|
53
57
|
@api.post(@workspace, 'users.setPresence', { presence: presence })
|
|
54
58
|
end
|
|
@@ -63,6 +67,12 @@ module Slk
|
|
|
63
67
|
@api.post_form(@workspace, 'users.info', { user: user_id })
|
|
64
68
|
end
|
|
65
69
|
|
|
70
|
+
def profile_for(user_id, include_labels: true)
|
|
71
|
+
params = { user: user_id }
|
|
72
|
+
params[:include_labels] = true if include_labels
|
|
73
|
+
@api.post_form(@workspace, 'users.profile.get', params)
|
|
74
|
+
end
|
|
75
|
+
|
|
66
76
|
def get_prefs # rubocop:disable Naming/AccessorMethodName
|
|
67
77
|
@api.post(@workspace, 'users.prefs.get')
|
|
68
78
|
end
|
data/lib/slk/cli.rb
CHANGED
|
@@ -20,7 +20,10 @@ module Slk
|
|
|
20
20
|
'cache' => Commands::Cache,
|
|
21
21
|
'emoji' => Commands::Emoji,
|
|
22
22
|
'config' => Commands::Config,
|
|
23
|
-
'help' => Commands::Help
|
|
23
|
+
'help' => Commands::Help,
|
|
24
|
+
'debug' => Commands::Debug,
|
|
25
|
+
'who' => Commands::Who,
|
|
26
|
+
'org' => Commands::Org
|
|
24
27
|
}.freeze
|
|
25
28
|
|
|
26
29
|
def initialize(argv, output: nil)
|
|
@@ -69,7 +72,7 @@ module Slk
|
|
|
69
72
|
else
|
|
70
73
|
show_unknown_command(command_name)
|
|
71
74
|
end
|
|
72
|
-
rescue ConfigError, EncryptionError, ApiError => e
|
|
75
|
+
rescue ConfigError, EncryptionError, ApiError, UsageError => e
|
|
73
76
|
handle_known_error(e)
|
|
74
77
|
end
|
|
75
78
|
|
|
@@ -83,7 +86,10 @@ module Slk
|
|
|
83
86
|
def handle_known_error(error)
|
|
84
87
|
label = error_label(error)
|
|
85
88
|
@output.error(label ? "#{label}: #{error.message}" : error.message)
|
|
86
|
-
|
|
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)
|
|
87
93
|
1
|
|
88
94
|
end
|
|
89
95
|
|
|
@@ -140,6 +146,11 @@ module Slk
|
|
|
140
146
|
runner.api_client.on_request = lambda { |method, count|
|
|
141
147
|
output.debug("[API ##{count}] #{method}")
|
|
142
148
|
}
|
|
149
|
+
runner.api_client.on_response = lambda { |method, code, headers|
|
|
150
|
+
next unless code == 'rate-wait'
|
|
151
|
+
|
|
152
|
+
output.warn("Rate limited on #{method}; sleeping #{headers['sleep_seconds']}s and retrying...")
|
|
153
|
+
}
|
|
143
154
|
end
|
|
144
155
|
|
|
145
156
|
def setup_very_verbose_logging(runner, output)
|
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
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Commands
|
|
5
|
+
# Hidden command for development spikes.
|
|
6
|
+
# Dumps raw JSON from Slack endpoints to validate xoxc compatibility
|
|
7
|
+
# and field shapes before building higher-level features.
|
|
8
|
+
#
|
|
9
|
+
# Not registered in `slk help`.
|
|
10
|
+
class Debug < Base
|
|
11
|
+
def execute
|
|
12
|
+
result = validate_options
|
|
13
|
+
return result if result
|
|
14
|
+
|
|
15
|
+
dispatch_action
|
|
16
|
+
rescue ApiError => e
|
|
17
|
+
error("API error: #{e.message}")
|
|
18
|
+
1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def dispatch_action
|
|
22
|
+
case positional_args
|
|
23
|
+
in ['profile', user] then dump_profile(user)
|
|
24
|
+
in ['profile'] then dump_profile(nil)
|
|
25
|
+
in ['team'] then dump_team
|
|
26
|
+
in ['schema'] then dump_schema
|
|
27
|
+
else unknown_action
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def dump_profile(user_input)
|
|
34
|
+
workspace = runner.workspace(@options[:workspace])
|
|
35
|
+
user_id = resolve_user_id(workspace, user_input)
|
|
36
|
+
users_api = runner.users_api(workspace.name)
|
|
37
|
+
out = {
|
|
38
|
+
'users.profile.get' => users_api.profile_for(user_id),
|
|
39
|
+
'users.info' => users_api.info(user_id),
|
|
40
|
+
'team.profile.get' => runner.team_api(workspace.name).profile_schema
|
|
41
|
+
}
|
|
42
|
+
output.puts(JSON.pretty_generate(out))
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dump_team
|
|
47
|
+
workspace = runner.workspace(@options[:workspace])
|
|
48
|
+
output.puts(JSON.pretty_generate(runner.team_api(workspace.name).info))
|
|
49
|
+
0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dump_schema
|
|
53
|
+
workspace = runner.workspace(@options[:workspace])
|
|
54
|
+
output.puts(JSON.pretty_generate(runner.team_api(workspace.name).profile_schema))
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_user_id(workspace, user_input)
|
|
59
|
+
return self_user_id(workspace) if user_input.nil? || user_input == 'me'
|
|
60
|
+
return user_input if user_input.match?(/\A[UW][A-Z0-9]+\z/)
|
|
61
|
+
|
|
62
|
+
id = lookup_for(workspace).find_id_by_name(user_input.delete_prefix('@'))
|
|
63
|
+
raise ApiError, "Could not resolve user: #{user_input}" unless id
|
|
64
|
+
|
|
65
|
+
id
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def lookup_for(workspace)
|
|
69
|
+
Services::UserLookup.new(
|
|
70
|
+
cache_store: cache_store,
|
|
71
|
+
workspace: workspace,
|
|
72
|
+
api_client: api_client,
|
|
73
|
+
on_debug: ->(msg) { output.debug(msg) }
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self_user_id(workspace)
|
|
78
|
+
client = Api::Client.new(api_client, workspace)
|
|
79
|
+
response = client.auth_test
|
|
80
|
+
response['user_id']
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def unknown_action
|
|
84
|
+
error("Unknown debug action: #{positional_args.first.inspect}")
|
|
85
|
+
error('Valid actions: profile [user], team, schema')
|
|
86
|
+
1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
protected
|
|
90
|
+
|
|
91
|
+
def help_text
|
|
92
|
+
<<~HELP
|
|
93
|
+
slk debug <action> [args]
|
|
94
|
+
|
|
95
|
+
Hidden development command — dumps raw API responses for spike validation.
|
|
96
|
+
|
|
97
|
+
ACTIONS
|
|
98
|
+
profile [user] Dump users.profile.get + users.info + team.profile.get
|
|
99
|
+
team Dump team.info
|
|
100
|
+
schema Dump team.profile.get
|
|
101
|
+
|
|
102
|
+
USER
|
|
103
|
+
(none) | me Self
|
|
104
|
+
@handle | name Resolved via user cache
|
|
105
|
+
Uxxx Raw user ID
|
|
106
|
+
HELP
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
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
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Commands
|
|
5
|
+
# Walk the Slack org chart by following Supervisor custom profile fields.
|
|
6
|
+
# Examples:
|
|
7
|
+
# slk org # self, supervisors up to depth 3
|
|
8
|
+
# slk org @alex # alex's chain
|
|
9
|
+
# slk org Uxxx --depth 5
|
|
10
|
+
# slk org --down # reports (best-effort, requires reindex for completeness)
|
|
11
|
+
class Org < Base
|
|
12
|
+
DEFAULT_DEPTH = 5
|
|
13
|
+
|
|
14
|
+
def execute
|
|
15
|
+
result = validate_options
|
|
16
|
+
return result if result
|
|
17
|
+
|
|
18
|
+
run
|
|
19
|
+
rescue ApiError => e
|
|
20
|
+
error("API error: #{e.message}")
|
|
21
|
+
1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
def handle_option(arg, args, _remaining)
|
|
27
|
+
case arg
|
|
28
|
+
when '--up' then @options[:direction] = :up
|
|
29
|
+
when '--down' then @options[:direction] = :down
|
|
30
|
+
when '--depth' then @options[:depth] = args.shift.to_i
|
|
31
|
+
else return super
|
|
32
|
+
end
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def help_text
|
|
37
|
+
<<~HELP
|
|
38
|
+
slk org [target]
|
|
39
|
+
|
|
40
|
+
Walk the Slack org chart by Supervisor field.
|
|
41
|
+
|
|
42
|
+
OPTIONS
|
|
43
|
+
--up Walk supervisors upward (default)
|
|
44
|
+
--down Show direct reports (best-effort, see note)
|
|
45
|
+
--depth N Levels to walk (default #{DEFAULT_DEPTH})
|
|
46
|
+
|
|
47
|
+
NOTES
|
|
48
|
+
--down currently scans cached profiles only. Run `slk who` on a
|
|
49
|
+
user to seed their profile in cache; full crawl support is TODO.
|
|
50
|
+
HELP
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def run
|
|
56
|
+
workspace = runner.workspace(@options[:workspace])
|
|
57
|
+
resolver = runner.profile_resolver(workspace.name, refresh: @options[:refresh])
|
|
58
|
+
user_id = resolve_user_id(workspace)
|
|
59
|
+
target = resolver.resolve(user_id)
|
|
60
|
+
@self_user_id = self_user_id(workspace)
|
|
61
|
+
|
|
62
|
+
case @options[:direction] || :up
|
|
63
|
+
when :up then render_up(resolver, target)
|
|
64
|
+
when :down then render_down(target)
|
|
65
|
+
end
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_up(resolver, target)
|
|
70
|
+
chain = resolver.resolve_chain_up(target.user_id, depth: @options[:depth] || DEFAULT_DEPTH)
|
|
71
|
+
if chain.empty?
|
|
72
|
+
info("No supervisor on #{target.best_name}'s profile.")
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
chain.reverse_each.with_index do |profile, depth|
|
|
77
|
+
render_node(profile, depth, you: profile.user_id == @self_user_id)
|
|
78
|
+
end
|
|
79
|
+
render_node(target, chain.size, you: target.user_id == @self_user_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_down(target)
|
|
83
|
+
warn('slk org --down is best-effort against cached profiles only.')
|
|
84
|
+
warn('Run `slk who <user>` to seed their profile, or wait for `slk org reindex` (TODO).')
|
|
85
|
+
info("Direct reports for #{target.best_name}: lookup not yet wired (Phase 4).")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_node(profile, depth, you: false)
|
|
89
|
+
prefix = depth.zero? ? '' : "#{' ' * depth}└─ "
|
|
90
|
+
marker = you ? output.bold(' ← you') : ''
|
|
91
|
+
title = profile.title.to_s.empty? ? '' : " — #{output.gray(profile.title)}"
|
|
92
|
+
output.puts("#{prefix}#{profile.best_name}#{title}#{marker}")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_user_id(workspace)
|
|
96
|
+
target = positional_args.first
|
|
97
|
+
return self_user_id(workspace) if target.nil? || target == 'me'
|
|
98
|
+
return target if target.match?(/\A[UW][A-Z0-9]+\z/)
|
|
99
|
+
|
|
100
|
+
Services::UserLookup.new(
|
|
101
|
+
cache_store: cache_store,
|
|
102
|
+
workspace: workspace,
|
|
103
|
+
api_client: api_client,
|
|
104
|
+
on_debug: ->(msg) { output.debug(msg) }
|
|
105
|
+
).find_id_by_name(target.delete_prefix('@')) ||
|
|
106
|
+
(raise ApiError, "Could not resolve user: #{target}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self_user_id(workspace)
|
|
110
|
+
cached = cache_store.get_meta(workspace.name, 'self_user_id')
|
|
111
|
+
return cached if cached
|
|
112
|
+
|
|
113
|
+
user_id = Api::Client.new(api_client, workspace).auth_test['user_id']
|
|
114
|
+
cache_store.set_meta(workspace.name, 'self_user_id', user_id) if user_id
|
|
115
|
+
user_id
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|