slk 0.5.0 → 0.6.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 +53 -0
- data/lib/slk/api/team.rb +24 -0
- data/lib/slk/api/users.rb +10 -0
- data/lib/slk/cli.rb +9 -1
- data/lib/slk/commands/debug.rb +110 -0
- data/lib/slk/commands/org.rb +119 -0
- 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/runner.rb +17 -0
- data/lib/slk/services/api_client.rb +75 -10
- data/lib/slk/services/cache_store.rb +46 -2
- 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/version.rb +1 -1
- data/lib/slk.rb +44 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 139ba582e24a6b92cd79493c0000a1479fbeb4ce59c90d883d0bbb603e8ff4c7
|
|
4
|
+
data.tar.gz: 81bdd74fc8a1d9b44480272098f78744297b3bd2b42b39dbc25c7f1fdb891c2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f79c404a79de6d32c77e85226b92b65322ff159ae610201d3514abc66fc9f8497a30c29da802b81e0ed2f36cdb3bc805835eee0e85f744f0f323d2620391c483
|
|
7
|
+
data.tar.gz: 6fff7a3b9b11762d179ccd2d22e5a0e4a6e395f52bd4fe89810cb045fed144c989026c07928179efdcd35e8ac3c3c991dab8f90344042cd0475adc8fc1007d7e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-04-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`slk who [target]`** — compact teems-style profile card for self or any user
|
|
15
|
+
- Targets: positional arg accepts `Uxxx`, display name, real name, or email; defaults to self
|
|
16
|
+
- `--full` expands into Contact / People / About me sections matching the Slack web profile
|
|
17
|
+
- `--json` emits the full Profile struct for piping
|
|
18
|
+
- `--refresh` bypasses both the per-run memo and the on-disk meta cache
|
|
19
|
+
- `--all` prints every match in turn; `--pick N` picks the Nth match non-interactively
|
|
20
|
+
- Multi-match disambiguation: prints a numbered list on stderr and prompts on a TTY; non-TTY contexts raise instead of silently picking
|
|
21
|
+
- Renders Slack Connect external users with a stripped layout (`external — <home workspace>`)
|
|
22
|
+
- Marks deactivated accounts with a bold `deactivated account` tag (and `deactivated: true` in JSON)
|
|
23
|
+
- 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)
|
|
24
|
+
- **`slk org [target]`** — walks the supervisor chain upward from the target
|
|
25
|
+
- `--depth N` caps traversal (default 5); cycle-safe via seen-set
|
|
26
|
+
- Indented tree with `└─ ├─ │` glyphs; `← you` marker on whichever node is the authenticated user
|
|
27
|
+
- New `Api::Team` wrapper for `team.info` and `team.profile.get`, plus `Api::Users#profile_for(user_id, include_labels:)`
|
|
28
|
+
- `Services::ProfileResolver`, `ProfileBuilder`, `UserMatcher`, `UserPicker`, `WhoTargetResolver`, `MetaCache` services backing the new commands
|
|
29
|
+
- Hidden `slk debug profile <uid>` subcommand for inspecting raw profile/info/schema responses
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `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
|
|
34
|
+
- `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
|
|
35
|
+
- CI matrix now uses `bundler-cache` and runs `bundle exec rake test`; new `coverage` job enforces a 95/95 line/branch SimpleCov threshold
|
|
36
|
+
- Pinned `parallel < 2.0` in dev/test bundle to keep Ruby 3.2 compatible with current rubocop
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- `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
|
|
41
|
+
|
|
42
|
+
## [0.5.0] - 2026-04-13
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **`--fetch-attachments` flag** - Download message files and attachment images to local cache
|
|
47
|
+
- Downloads Slack files (authed) and public attachment images (Giphy, Tenor, etc.)
|
|
48
|
+
- Cached to `~/.cache/slk/files/{workspace}/` with skip-on-rerun
|
|
49
|
+
- Shows copyable local file paths in output: `[File: /path/to/file.png]`
|
|
50
|
+
- Works with `messages`, `thread`, and `--threads` inline replies
|
|
51
|
+
- Summary line when files are present: `9 files not downloaded. Use --fetch-attachments to download.`
|
|
52
|
+
- Follows up to 3 redirect hops with relative URL resolution
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- Added `rubocop` as a dev dependency for linting
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **`thread` command** - Extracted `resolve_and_display_thread` to fix rubocop complexity warnings
|
|
61
|
+
|
|
10
62
|
## [0.4.2] - 2026-03-01
|
|
11
63
|
|
|
12
64
|
### Added
|
|
@@ -131,6 +183,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
|
|
|
131
183
|
- Pure Ruby stdlib - no gem dependencies
|
|
132
184
|
- Ruby 3.2+ with modern features (Data.define, pattern matching)
|
|
133
185
|
|
|
186
|
+
[0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
|
|
134
187
|
[0.4.2]: https://github.com/ericboehs/slk/releases/tag/v0.4.2
|
|
135
188
|
[0.4.0]: https://github.com/ericboehs/slk/releases/tag/v0.4.0
|
|
136
189
|
[0.3.0]: https://github.com/ericboehs/slk/releases/tag/v0.3.0
|
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)
|
|
@@ -140,6 +143,11 @@ module Slk
|
|
|
140
143
|
runner.api_client.on_request = lambda { |method, count|
|
|
141
144
|
output.debug("[API ##{count}] #{method}")
|
|
142
145
|
}
|
|
146
|
+
runner.api_client.on_response = lambda { |method, code, headers|
|
|
147
|
+
next unless code == 'rate-wait'
|
|
148
|
+
|
|
149
|
+
output.warn("Rate limited on #{method}; sleeping #{headers['sleep_seconds']}s and retrying...")
|
|
150
|
+
}
|
|
143
151
|
end
|
|
144
152
|
|
|
145
153
|
def setup_very_verbose_logging(runner, output)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Commands
|
|
5
|
+
# Display a Slack user profile (teems-style compact card by default).
|
|
6
|
+
# Examples:
|
|
7
|
+
# slk who # self
|
|
8
|
+
# slk who @alex
|
|
9
|
+
# slk who alice
|
|
10
|
+
# slk who Uxxx --full
|
|
11
|
+
# slk who --json
|
|
12
|
+
class Who < Base
|
|
13
|
+
def execute
|
|
14
|
+
result = validate_options
|
|
15
|
+
return result if result
|
|
16
|
+
|
|
17
|
+
run
|
|
18
|
+
rescue ApiError => e
|
|
19
|
+
error("API error: #{e.message}")
|
|
20
|
+
1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
def handle_option(arg, args, _remaining)
|
|
26
|
+
case arg
|
|
27
|
+
when '--full' then @options[:full] = true
|
|
28
|
+
when '--no-cache', '--refresh' then @options[:refresh] = true
|
|
29
|
+
when '--all' then @options[:all] = true
|
|
30
|
+
when '--pick' then @options[:pick] = parse_pick(args.shift)
|
|
31
|
+
else return super
|
|
32
|
+
end
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def help_text
|
|
37
|
+
<<~HELP
|
|
38
|
+
slk who [target]
|
|
39
|
+
|
|
40
|
+
Show a Slack user profile.
|
|
41
|
+
|
|
42
|
+
TARGET
|
|
43
|
+
(none) Self
|
|
44
|
+
@handle | name Resolved via user cache
|
|
45
|
+
Uxxx Raw user ID
|
|
46
|
+
|
|
47
|
+
OPTIONS
|
|
48
|
+
--full Section-grouped layout (Contact, People, About me)
|
|
49
|
+
--json Raw JSON output
|
|
50
|
+
--refresh Bypass cache
|
|
51
|
+
--all Render every match (skips the disambiguation prompt)
|
|
52
|
+
--pick N Auto-select match N when a name resolves to multiple
|
|
53
|
+
HELP
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_pick(value)
|
|
59
|
+
Integer(value)
|
|
60
|
+
rescue ArgumentError, TypeError
|
|
61
|
+
raise ApiError, "--pick expects an integer (got #{value.inspect})"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run
|
|
65
|
+
workspace = runner.workspace(@options[:workspace])
|
|
66
|
+
profiles = resolve_profiles(workspace)
|
|
67
|
+
return output_json_profiles(profiles) if @options[:json]
|
|
68
|
+
|
|
69
|
+
render_profiles(profiles)
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_profiles(workspace)
|
|
74
|
+
resolver = Services::WhoTargetResolver.new(
|
|
75
|
+
workspace: workspace, cache_store: cache_store,
|
|
76
|
+
api_client: api_client, output: output, options: @options
|
|
77
|
+
)
|
|
78
|
+
resolver.resolve(positional_args.first).map { |id| load_profile(workspace, id) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def load_profile(workspace, user_id)
|
|
82
|
+
runner.profile_resolver(workspace.name, refresh: @options[:refresh])
|
|
83
|
+
.resolve_with_people(user_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_profiles(profiles)
|
|
87
|
+
profiles.each_with_index do |profile, idx|
|
|
88
|
+
output.puts(output.gray('—' * 40)) if idx.positive?
|
|
89
|
+
render(profile)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def render(profile)
|
|
94
|
+
formatter = Formatters::ProfileFormatter.new(
|
|
95
|
+
output: output, emoji_replacer: runner.emoji_replacer
|
|
96
|
+
)
|
|
97
|
+
@options[:full] ? formatter.full(profile) : formatter.compact(profile)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def output_json_profiles(profiles)
|
|
101
|
+
payload = profiles.map { |p| profile_payload(p) }
|
|
102
|
+
output_json(profiles.size == 1 ? payload.first : payload)
|
|
103
|
+
0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def profile_payload(profile)
|
|
107
|
+
profile.to_h.merge(
|
|
108
|
+
custom_fields: profile.visible_fields.map(&:to_h),
|
|
109
|
+
sections: profile.sections,
|
|
110
|
+
resolved_users: profile.resolved_users.transform_values(&:to_h)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders ProfileField values to display strings (date with relative
|
|
6
|
+
# phrasing, link with alt text, type:user dereferenced via resolved_users).
|
|
7
|
+
class ProfileFieldRenderer
|
|
8
|
+
def initialize(output:)
|
|
9
|
+
@output = output
|
|
10
|
+
@hyperlinks = output.respond_to?(:color?) && output.color?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(field, profile)
|
|
14
|
+
case field.type
|
|
15
|
+
when 'date' then format_date(field.value)
|
|
16
|
+
when 'link' then format_link(field)
|
|
17
|
+
when 'user' then format_user_list(field, profile)
|
|
18
|
+
else format_text(field.value)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Slack stores some text fields (notably free-text profile blocks) as
|
|
23
|
+
# rich_text JSON blocks. Detect and flatten to plain text.
|
|
24
|
+
def format_text(value)
|
|
25
|
+
text = value.to_s
|
|
26
|
+
return text unless text.start_with?('[{') && text.include?('rich_text')
|
|
27
|
+
|
|
28
|
+
flatten_rich_text(JSON.parse(text))
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
text
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def flatten_rich_text(blocks)
|
|
34
|
+
Array(blocks).flat_map { |block| extract_text_from_block(block) }.join
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_text_from_block(block)
|
|
38
|
+
return [block.to_s] unless block.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
return [block['text'].to_s] if block['type'] == 'text'
|
|
41
|
+
return [block['name'] ? ":#{block['name']}:" : ''] if block['type'] == 'emoji'
|
|
42
|
+
|
|
43
|
+
Array(block['elements']).flat_map { |el| extract_text_from_block(el) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render_user_reference(user_id, profile)
|
|
47
|
+
ref = profile.resolved_users[user_id]
|
|
48
|
+
return user_id unless ref
|
|
49
|
+
|
|
50
|
+
suffix = ref.title.to_s.empty? ? '' : " — #{ref.title}"
|
|
51
|
+
pronouns = ref.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{ref.pronouns})")}"
|
|
52
|
+
"#{ref.best_name}#{pronouns}#{suffix}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def format_user_list(field, profile)
|
|
58
|
+
field.user_ids.map { |uid| render_user_reference(uid, profile) }.join(', ')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_date(value)
|
|
62
|
+
date = parse_date(value) or return value
|
|
63
|
+
relative = relative_phrase(date, Date.today)
|
|
64
|
+
formatted = date.strftime('%b %-d, %Y')
|
|
65
|
+
relative ? "#{formatted} (#{relative})" : formatted
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_date(value)
|
|
69
|
+
Date.parse(value.to_s)
|
|
70
|
+
rescue ArgumentError, TypeError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def relative_phrase(then_date, now_date)
|
|
75
|
+
return nil if then_date >= now_date
|
|
76
|
+
|
|
77
|
+
years, months = years_months_between(then_date, now_date)
|
|
78
|
+
parts = []
|
|
79
|
+
parts << "#{years}y" if years.positive?
|
|
80
|
+
parts << "#{months}mo" if months.positive?
|
|
81
|
+
parts.empty? ? nil : "#{parts.join(' ')} ago"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def years_months_between(then_date, now_date)
|
|
85
|
+
months = ((now_date.year - then_date.year) * 12) + (now_date.month - then_date.month)
|
|
86
|
+
months -= 1 if now_date.day < then_date.day
|
|
87
|
+
[months / 12, months % 12]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def format_link(field)
|
|
91
|
+
url = field.value.to_s
|
|
92
|
+
label = field.alt.to_s.empty? ? url : field.alt
|
|
93
|
+
return url if label == url
|
|
94
|
+
return "#{label} (#{shorten_url(url)})" unless @hyperlinks
|
|
95
|
+
|
|
96
|
+
# OSC 8 hyperlink — clickable in iTerm2, Ghostty, Wezterm, Kitty, Vte.
|
|
97
|
+
"\e]8;;#{url}\a#{label}\e]8;;\a"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def shorten_url(url)
|
|
101
|
+
URI.parse(url).host || url
|
|
102
|
+
rescue URI::InvalidURIError
|
|
103
|
+
url
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|