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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Orchestrates the API calls needed to assemble a Models::Profile.
6
+ # Memoizes within the instance — one resolver per command run.
7
+ class ProfileResolver
8
+ SCHEMA_TTL = 86_400 # 24h
9
+ PROFILE_TTL = 3_600 # 1h
10
+ EMPTY_SCHEMA = { 'ok' => false, 'profile' => { 'fields' => [], 'sections' => [] } }.freeze
11
+
12
+ attr_accessor :refresh
13
+
14
+ def initialize(users_api:, team_api:, cache_store: nil, workspace_name: nil, on_debug: nil)
15
+ @users_api = users_api
16
+ @team_api = team_api
17
+ @cache_store = cache_store
18
+ @workspace_name = workspace_name
19
+ @on_debug = on_debug
20
+ @refresh = false
21
+ @profile_cache = {}
22
+ @home_team_names = {}
23
+ end
24
+
25
+ # Resolve a user ID to a Profile. Memoized per resolver instance.
26
+ def resolve(user_id)
27
+ return @profile_cache[user_id] if @profile_cache.key?(user_id)
28
+
29
+ @profile_cache[user_id] = build_profile(user_id)
30
+ end
31
+
32
+ # Resolve a profile and one level of type:user custom fields, populating
33
+ # `resolved_users` so the formatter can render the People section.
34
+ def resolve_with_people(user_id)
35
+ profile = resolve(user_id)
36
+ return profile if profile.external?
37
+
38
+ profile.people_fields.flat_map(&:user_ids).uniq.each do |ref_id|
39
+ profile.resolved_users[ref_id] ||= resolve(ref_id)
40
+ end
41
+ profile
42
+ end
43
+
44
+ # Walks Supervisor (or first non-inverse type:user field) upward.
45
+ # Returns Array<Profile> from immediate supervisor to top, capped by depth.
46
+ def resolve_chain_up(user_id, depth: 5)
47
+ chain = []
48
+ seen = Set.new([user_id])
49
+ current = resolve(user_id)
50
+ depth.times { current = step_up(current, seen, chain) or break }
51
+ chain
52
+ end
53
+
54
+ private
55
+
56
+ def step_up(current, seen, chain)
57
+ parent_id = current.supervisor_ids.first
58
+ return nil unless parent_id && !seen.include?(parent_id)
59
+
60
+ parent = resolve(parent_id)
61
+ chain << parent
62
+ seen << parent.user_id
63
+ parent
64
+ end
65
+
66
+ def build_profile(user_id)
67
+ profile = ProfileBuilder.build(
68
+ profile_response: fetch_profile_response(user_id),
69
+ info_response: cache_or_fetch("ui_#{user_id}", ttl: PROFILE_TTL) { @users_api.info(user_id) },
70
+ schema_response: schema,
71
+ workspace_team_id: workspace_team_id
72
+ )
73
+ attach_extras(profile, user_id)
74
+ rescue ApiError => e
75
+ @on_debug&.call("Profile resolve failed for #{user_id}: #{e.message}")
76
+ raise
77
+ end
78
+
79
+ # Only swallow `user_not_found` (Slack Connect); other errors propagate.
80
+ def fetch_profile_response(user_id)
81
+ key = "up_#{user_id}"
82
+ cached = MetaCache.read(@cache_store, @workspace_name, key, ttl: PROFILE_TTL) unless @refresh
83
+ return cached if cached
84
+
85
+ response = @users_api.profile_for(user_id)
86
+ MetaCache.write(@cache_store, @workspace_name, key, response)
87
+ response
88
+ rescue ApiError => e
89
+ raise unless e.code == :user_not_found
90
+
91
+ @on_debug&.call("#{key}: #{e.message} (falling back to users.info)")
92
+ nil
93
+ end
94
+
95
+ def attach_extras(profile, user_id)
96
+ profile = attach_home_team_name(profile)
97
+ presence = fetch_presence(user_id)
98
+ presence ? Models::Profile.new(**profile.to_h, presence: presence) : profile
99
+ end
100
+
101
+ def fetch_presence(user_id)
102
+ @users_api.get_presence_for(user_id)&.dig('presence')
103
+ rescue ApiError => e
104
+ @on_debug&.call("get_presence_for(#{user_id}) failed: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ def schema
109
+ @schema ||= cache_or_fetch('team_profile_schema', ttl: SCHEMA_TTL,
110
+ empty: EMPTY_SCHEMA) { @team_api.profile_schema }
111
+ end
112
+
113
+ def workspace_team_id
114
+ @workspace_team_id ||= cache_or_fetch('workspace_team_id') { @team_api.info.dig('team', 'id') }
115
+ end
116
+
117
+ def cache_or_fetch(key, ttl: nil, empty: nil, &)
118
+ MetaCache.fetch(@cache_store, @workspace_name, key, ttl: ttl, refresh: @refresh, &)
119
+ rescue ApiError => e
120
+ @on_debug&.call("#{key} fetch failed: #{e.message}")
121
+ empty
122
+ end
123
+
124
+ def attach_home_team_name(profile)
125
+ return profile unless profile.external? && profile.team_id && (name = home_team_name(profile.team_id))
126
+
127
+ Models::Profile.new(**profile.to_h, home_team_name: name)
128
+ end
129
+
130
+ def home_team_name(team_id)
131
+ @home_team_names[team_id] ||= @team_api.info(team_id).dig('team', 'name')
132
+ rescue ApiError => e
133
+ @on_debug&.call("team.info(#{team_id}) failed: #{e.message}")
134
+ @home_team_names[team_id] = nil
135
+ end
136
+ end
137
+ end
138
+ end
@@ -52,6 +52,14 @@ module Slk
52
52
  fetch_id_by_name(name)
53
53
  end
54
54
 
55
+ # @return [Array<Hash>] raw users.list-shaped hashes for all matches
56
+ def find_all_by_name(name)
57
+ UserMatcher.new(
58
+ api_client: @api, workspace: @workspace,
59
+ cache_store: @cache, on_debug: @on_debug
60
+ ).find_all(name)
61
+ end
62
+
55
63
  private
56
64
 
57
65
  def fetch_and_cache_name(user_id)
@@ -88,11 +96,7 @@ module Slk
88
96
  end
89
97
 
90
98
  def fetch_id_by_name(name)
91
- return nil unless @api
92
-
93
- users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
94
- users = users_api.list['members'] || []
95
- user = find_user_by_name(users, name)
99
+ user = find_all_by_name(name).first
96
100
  cache_user_from_api(user) if user
97
101
  user&.dig('id')
98
102
  rescue ApiError => e
@@ -100,14 +104,6 @@ module Slk
100
104
  nil
101
105
  end
102
106
 
103
- def find_user_by_name(users, name)
104
- users.find do |u|
105
- u['name'] == name ||
106
- u.dig('profile', 'display_name') == name ||
107
- u.dig('profile', 'real_name') == name
108
- end
109
- end
110
-
111
107
  def cache_user_from_api(user_data)
112
108
  user = Models::User.from_api(user_data)
113
109
  @cache.set_user(@workspace.name, user.id, user.best_name, persist: true)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Finds all users whose name/display/real/first+last matches a query
6
+ # (case-insensitive). Combines users.list with previously-resolved profiles
7
+ # cached locally — Slack Connect external users don't appear in users.list
8
+ # but do show up in the meta cache once users.info has been fetched.
9
+ class UserMatcher
10
+ def initialize(api_client:, workspace:, cache_store:, on_debug: nil)
11
+ @api = api_client
12
+ @workspace = workspace
13
+ @cache = cache_store
14
+ @on_debug = on_debug
15
+ end
16
+
17
+ # Returns users.list-shaped hashes (deduped by id). Raises ApiError on
18
+ # network/auth failures so callers don't conflate them with "no matches".
19
+ def find_all(name)
20
+ return [] if name.to_s.empty? || @api.nil?
21
+
22
+ target = name.downcase
23
+ candidates = list_members + cached_profile_users
24
+ unique_by_id(candidates.select { |u| matches?(u, target) })
25
+ end
26
+
27
+ def matches?(user, target_lower)
28
+ name_candidates(user).any? { |c| c.downcase == target_lower }
29
+ end
30
+
31
+ def name_candidates(user)
32
+ profile = user['profile'] || {}
33
+ full = [profile['first_name'], profile['last_name']].compact.join(' ').strip
34
+ [user['name'], profile['display_name'], profile['real_name'], full]
35
+ .map(&:to_s).reject(&:empty?)
36
+ end
37
+
38
+ private
39
+
40
+ def list_members
41
+ Api::Users.new(@api, @workspace, on_debug: @on_debug).list['members'] || []
42
+ end
43
+
44
+ # Reshape cached `ui_<uid>` meta entries (raw users.info responses) into
45
+ # users.list-shaped hashes so the matcher can compare them uniformly.
46
+ def cached_profile_users
47
+ return [] unless @cache.respond_to?(:each_meta)
48
+
49
+ @cache.each_meta(@workspace.name).filter_map do |key, value|
50
+ next unless key.start_with?('ui_')
51
+
52
+ user = value.is_a?(Hash) ? value.dig('value', 'user') : nil
53
+ user if user.is_a?(Hash) && user['id']
54
+ end
55
+ end
56
+
57
+ def unique_by_id(users)
58
+ seen = {}
59
+ users.each { |u| seen[u['id']] ||= u }
60
+ seen.values
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Disambiguates between multiple matching users. Prompts at a TTY; raises
6
+ # ApiError in non-interactive contexts so callers don't silently get the
7
+ # wrong user when name resolution is ambiguous.
8
+ class UserPicker
9
+ def initialize(stdin: $stdin, prompt_io: $stderr)
10
+ @stdin = stdin
11
+ @prompt_io = prompt_io
12
+ end
13
+
14
+ def pick(matches)
15
+ return matches.first['id'] if matches.size == 1
16
+
17
+ unless interactive?
18
+ raise ApiError,
19
+ "Ambiguous match (#{matches.size} users): #{ids(matches).join(', ')}. " \
20
+ 'Use --pick N or --all to disambiguate non-interactively.'
21
+ end
22
+
23
+ list(matches)
24
+ matches[read_index(matches.size)]['id']
25
+ end
26
+
27
+ private
28
+
29
+ def interactive?
30
+ @stdin.respond_to?(:tty?) && @stdin.tty?
31
+ end
32
+
33
+ def list(matches)
34
+ @prompt_io.puts('Multiple users match — pick one:')
35
+ matches.each_with_index { |u, i| @prompt_io.puts(" [#{i + 1}] #{describe(u)}") }
36
+ end
37
+
38
+ def describe(user)
39
+ profile = user['profile'] || {}
40
+ name = profile['real_name'] || profile['display_name'] || user['name']
41
+ suffix = profile['title'].to_s.empty? ? '' : " — #{profile['title']}"
42
+ "#{name} (#{user['id']})#{suffix}#{flag_suffix(user)}"
43
+ end
44
+
45
+ def flag_suffix(user)
46
+ flags = []
47
+ flags << 'deactivated' if user['deleted']
48
+ flags << 'bot' if user['is_bot']
49
+ flags.empty? ? '' : " [#{flags.join(', ')}]"
50
+ end
51
+
52
+ def ids(matches)
53
+ matches.map { |u| u['id'] }
54
+ end
55
+
56
+ def read_index(count)
57
+ loop do
58
+ @prompt_io.print("Choice [1-#{count}]: ")
59
+ choice = @stdin.gets&.strip
60
+ raise ApiError, 'No selection made' if choice.nil? || choice.empty?
61
+
62
+ n = Integer(choice, exception: false)
63
+ return n - 1 if n&.between?(1, count)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Resolves the positional target for `slk who` into one or more user_ids.
6
+ class WhoTargetResolver
7
+ def initialize(workspace:, cache_store:, api_client:, output:, options:)
8
+ @workspace = workspace
9
+ @cache_store = cache_store
10
+ @api_client = api_client
11
+ @output = output
12
+ @options = options
13
+ end
14
+
15
+ def resolve(target)
16
+ return [self_user_id] if target.nil? || target == 'me'
17
+ return [target] if target.match?(/\A[UW][A-Z0-9]+\z/)
18
+
19
+ ids = resolve_by_name(target.delete_prefix('@'))
20
+ ids || (raise ApiError, "Could not resolve user: #{target}")
21
+ end
22
+
23
+ private
24
+
25
+ def resolve_by_name(name)
26
+ matches = lookup.find_all_by_name(name)
27
+ return select(matches) if matches.any?
28
+
29
+ cached = @cache_store.get_user_id_by_name(@workspace.name, name)
30
+ cached ? [cached] : nil
31
+ end
32
+
33
+ def select(matches)
34
+ return matches.map { |u| u['id'] } if @options[:all]
35
+ return [pick_by_index(matches)] if @options[:pick]
36
+
37
+ [UserPicker.new.pick(matches)]
38
+ end
39
+
40
+ def pick_by_index(matches)
41
+ idx = @options[:pick]
42
+ raise ApiError, "--pick #{idx} out of range (got #{matches.size} matches)" unless idx&.between?(1, matches.size)
43
+
44
+ matches[idx - 1]['id']
45
+ end
46
+
47
+ def lookup
48
+ UserLookup.new(
49
+ cache_store: @cache_store, workspace: @workspace,
50
+ api_client: @api_client, on_debug: ->(msg) { @output.debug(msg) }
51
+ )
52
+ end
53
+
54
+ def self_user_id
55
+ cached = @cache_store.get_meta(@workspace.name, 'self_user_id')
56
+ return cached if cached
57
+
58
+ user_id = Api::Client.new(@api_client, @workspace).auth_test['user_id']
59
+ @cache_store.set_meta(@workspace.name, 'self_user_id', user_id) if user_id
60
+ user_id
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -11,7 +11,35 @@ require 'io/console'
11
11
  # Slack CLI - A command-line interface for Slack
12
12
  module Slk
13
13
  class Error < StandardError; end
14
- class ApiError < Error; end
14
+
15
+ # Errors from any Slack-API-shaped failure: HTTP errors, network errors,
16
+ # logical Slack errors (user_not_found, missing_scope, etc.), JSON parse
17
+ # failures. The optional `code` symbol lets callers match specific cases
18
+ # (e.g. `e.code == :user_not_found`) without parsing message strings.
19
+ #
20
+ # Codes used:
21
+ # :network_error, :http_error, :unauthorized, :invalid_json,
22
+ # :ratelimited, plus any literal Slack `error` value (`:user_not_found`,
23
+ # `:missing_scope`, `:account_inactive`, etc.).
24
+ class ApiError < Error
25
+ attr_reader :code
26
+
27
+ def initialize(message, code: nil)
28
+ super(message)
29
+ @code = code
30
+ end
31
+ end
32
+
33
+ # Slack rate-limit error. Carries Retry-After in seconds when present.
34
+ class RateLimitError < ApiError
35
+ attr_reader :retry_after
36
+
37
+ def initialize(message, retry_after: nil)
38
+ super(message, code: :ratelimited)
39
+ @retry_after = retry_after
40
+ end
41
+ end
42
+
15
43
  class ConfigError < Error; end
16
44
  class EncryptionError < Error; end
17
45
  class TokenStoreError < Error; end
@@ -34,6 +62,8 @@ module Slk
34
62
  autoload :Preset, 'slk/models/preset'
35
63
  autoload :SearchResult, 'slk/models/search_result'
36
64
  autoload :SavedItem, 'slk/models/saved_item'
65
+ autoload :Profile, 'slk/models/profile'
66
+ autoload :ProfileField, 'slk/models/profile_field'
37
67
  end
38
68
 
39
69
  # Application services for configuration, caching, and API communication
@@ -56,7 +86,13 @@ module Slk
56
86
  autoload :TargetResolver, 'slk/services/target_resolver'
57
87
  autoload :SetupWizard, 'slk/services/setup_wizard'
58
88
  autoload :UserLookup, 'slk/services/user_lookup'
89
+ autoload :UserMatcher, 'slk/services/user_matcher'
90
+ autoload :UserPicker, 'slk/services/user_picker'
91
+ autoload :WhoTargetResolver, 'slk/services/who_target_resolver'
59
92
  autoload :MessageResolver, 'slk/services/message_resolver'
93
+ autoload :ProfileBuilder, 'slk/services/profile_builder'
94
+ autoload :ProfileResolver, 'slk/services/profile_resolver'
95
+ autoload :MetaCache, 'slk/services/meta_cache'
60
96
  end
61
97
 
62
98
  # Output formatters for messages, durations, and emoji
@@ -75,6 +111,9 @@ module Slk
75
111
  autoload :SearchFormatter, 'slk/formatters/search_formatter'
76
112
  autoload :SavedItemFormatter, 'slk/formatters/saved_item_formatter'
77
113
  autoload :TextProcessor, 'slk/formatters/text_processor'
114
+ autoload :ProfileFormatter, 'slk/formatters/profile_formatter'
115
+ autoload :ProfileFieldRenderer, 'slk/formatters/profile_field_renderer'
116
+ autoload :ProfileRows, 'slk/formatters/profile_rows'
78
117
  end
79
118
 
80
119
  # CLI commands implementing user-facing functionality
@@ -96,6 +135,9 @@ module Slk
96
135
  autoload :Config, 'slk/commands/config'
97
136
  autoload :Help, 'slk/commands/help'
98
137
  autoload :Later, 'slk/commands/later'
138
+ autoload :Debug, 'slk/commands/debug'
139
+ autoload :Who, 'slk/commands/who'
140
+ autoload :Org, 'slk/commands/org'
99
141
  end
100
142
 
101
143
  # Thin wrappers around Slack API endpoints
@@ -111,6 +153,7 @@ module Slk
111
153
  autoload :Activity, 'slk/api/activity'
112
154
  autoload :Search, 'slk/api/search'
113
155
  autoload :Saved, 'slk/api/saved'
156
+ autoload :Team, 'slk/api/team'
114
157
  end
115
158
 
116
159
  # Utility classes for paths, parsing, and helpers
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -34,6 +34,7 @@ files:
34
34
  - lib/slk/api/emoji.rb
35
35
  - lib/slk/api/saved.rb
36
36
  - lib/slk/api/search.rb
37
+ - lib/slk/api/team.rb
37
38
  - lib/slk/api/threads.rb
38
39
  - lib/slk/api/usergroups.rb
39
40
  - lib/slk/api/users.rb
@@ -43,11 +44,13 @@ files:
43
44
  - lib/slk/commands/cache.rb
44
45
  - lib/slk/commands/catchup.rb
45
46
  - lib/slk/commands/config.rb
47
+ - lib/slk/commands/debug.rb
46
48
  - lib/slk/commands/dnd.rb
47
49
  - lib/slk/commands/emoji.rb
48
50
  - lib/slk/commands/help.rb
49
51
  - lib/slk/commands/later.rb
50
52
  - lib/slk/commands/messages.rb
53
+ - lib/slk/commands/org.rb
51
54
  - lib/slk/commands/presence.rb
52
55
  - lib/slk/commands/preset.rb
53
56
  - lib/slk/commands/search.rb
@@ -55,6 +58,7 @@ files:
55
58
  - lib/slk/commands/status.rb
56
59
  - lib/slk/commands/thread.rb
57
60
  - lib/slk/commands/unread.rb
61
+ - lib/slk/commands/who.rb
58
62
  - lib/slk/commands/workspaces.rb
59
63
  - lib/slk/formatters/activity_formatter.rb
60
64
  - lib/slk/formatters/attachment_formatter.rb
@@ -66,6 +70,9 @@ files:
66
70
  - lib/slk/formatters/mention_replacer.rb
67
71
  - lib/slk/formatters/message_formatter.rb
68
72
  - lib/slk/formatters/output.rb
73
+ - lib/slk/formatters/profile_field_renderer.rb
74
+ - lib/slk/formatters/profile_formatter.rb
75
+ - lib/slk/formatters/profile_rows.rb
69
76
  - lib/slk/formatters/reaction_formatter.rb
70
77
  - lib/slk/formatters/saved_item_formatter.rb
71
78
  - lib/slk/formatters/search_formatter.rb
@@ -74,6 +81,8 @@ files:
74
81
  - lib/slk/models/duration.rb
75
82
  - lib/slk/models/message.rb
76
83
  - lib/slk/models/preset.rb
84
+ - lib/slk/models/profile.rb
85
+ - lib/slk/models/profile_field.rb
77
86
  - lib/slk/models/reaction.rb
78
87
  - lib/slk/models/saved_item.rb
79
88
  - lib/slk/models/search_result.rb
@@ -91,7 +100,10 @@ files:
91
100
  - lib/slk/services/file_downloader.rb
92
101
  - lib/slk/services/gemoji_sync.rb
93
102
  - lib/slk/services/message_resolver.rb
103
+ - lib/slk/services/meta_cache.rb
94
104
  - lib/slk/services/preset_store.rb
105
+ - lib/slk/services/profile_builder.rb
106
+ - lib/slk/services/profile_resolver.rb
95
107
  - lib/slk/services/reaction_enricher.rb
96
108
  - lib/slk/services/setup_wizard.rb
97
109
  - lib/slk/services/target_resolver.rb
@@ -100,6 +112,9 @@ files:
100
112
  - lib/slk/services/token_store.rb
101
113
  - lib/slk/services/unread_marker.rb
102
114
  - lib/slk/services/user_lookup.rb
115
+ - lib/slk/services/user_matcher.rb
116
+ - lib/slk/services/user_picker.rb
117
+ - lib/slk/services/who_target_resolver.rb
103
118
  - lib/slk/support/date_parser.rb
104
119
  - lib/slk/support/error_logger.rb
105
120
  - lib/slk/support/help_formatter.rb