slk 0.4.2 → 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.
@@ -12,10 +12,37 @@ module Slk
12
12
  @user_cache = {}
13
13
  @channel_cache = {}
14
14
  @subteam_cache = {}
15
+ @meta_cache = {}
15
16
  @on_warning = nil
16
17
  @on_cache_access = nil
17
18
  end
18
19
 
20
+ # Workspace metadata cache (auth.test user_id, team.info team_id,
21
+ # team.profile.get schema). One JSON file per workspace; entries can
22
+ # carry an optional fetched_at epoch for TTL checks.
23
+ def get_meta(workspace_name, key, ttl: nil)
24
+ load_meta_cache(workspace_name)
25
+ entry = @meta_cache[workspace_name][key]
26
+ return nil unless entry
27
+ return nil if ttl && entry['fetched_at'] && Time.now.to_i - entry['fetched_at'] > ttl
28
+
29
+ log_cache_access('meta', workspace_name, key, entry['value'])
30
+ entry['value']
31
+ end
32
+
33
+ def set_meta(workspace_name, key, value, persist: true)
34
+ load_meta_cache(workspace_name)
35
+ @meta_cache[workspace_name] ||= {}
36
+ @meta_cache[workspace_name][key] = { 'value' => value, 'fetched_at' => Time.now.to_i }
37
+ save_meta_cache(workspace_name) if persist
38
+ value
39
+ end
40
+
41
+ def each_meta(workspace_name)
42
+ load_meta_cache(workspace_name)
43
+ (@meta_cache[workspace_name] || {}).each
44
+ end
45
+
19
46
  # User cache methods
20
47
  def get_user(workspace_name, user_id)
21
48
  load_user_cache(workspace_name)
@@ -126,12 +153,12 @@ module Slk
126
153
  # Cache status
127
154
  def user_cache_size(workspace_name)
128
155
  load_user_cache(workspace_name)
129
- @user_cache[workspace_name]&.size || 0
156
+ @user_cache[workspace_name].size
130
157
  end
131
158
 
132
159
  def channel_cache_size(workspace_name)
133
160
  load_channel_cache(workspace_name)
134
- @channel_cache[workspace_name]&.size || 0
161
+ @channel_cache[workspace_name].size
135
162
  end
136
163
 
137
164
  def user_cache_file_exists?(workspace_name)
@@ -208,6 +235,23 @@ module Slk
208
235
  @paths.cache_file("subteams-#{workspace_name}.json")
209
236
  end
210
237
 
238
+ def meta_cache_file(workspace_name)
239
+ @paths.cache_file("meta-#{workspace_name}.json")
240
+ end
241
+
242
+ def load_meta_cache(workspace_name)
243
+ return if @meta_cache.key?(workspace_name)
244
+
245
+ @meta_cache[workspace_name] = load_cache_file(meta_cache_file(workspace_name), 'Meta', workspace_name)
246
+ end
247
+
248
+ def save_meta_cache(workspace_name)
249
+ return if @meta_cache[workspace_name].nil? || @meta_cache[workspace_name].empty?
250
+
251
+ @paths.ensure_cache_dir
252
+ File.write(meta_cache_file(workspace_name), JSON.pretty_generate(@meta_cache[workspace_name]))
253
+ end
254
+
211
255
  def log_cache_access(type, workspace, key, result)
212
256
  return unless @on_cache_access
213
257
 
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Downloads Slack message files and attachment images to XDG cache dir.
6
+ # Authed files (url_private_download) require workspace headers.
7
+ # Public attachment images (image_url) are fetched without auth.
8
+ # rubocop:disable Metrics/ClassLength
9
+ class FileDownloader
10
+ NETWORK_ERRORS = [
11
+ SocketError,
12
+ Errno::ECONNREFUSED,
13
+ Errno::ETIMEDOUT,
14
+ Net::OpenTimeout,
15
+ Net::ReadTimeout,
16
+ URI::InvalidURIError,
17
+ OpenSSL::SSL::SSLError
18
+ ].freeze
19
+
20
+ IMAGE_TYPES = %w[png jpg jpeg gif bmp webp svg].freeze
21
+ MAX_REDIRECTS = 3
22
+
23
+ def initialize(cache_dir:, on_debug: nil)
24
+ @cache_dir = cache_dir
25
+ @on_debug = on_debug
26
+ end
27
+
28
+ # Download all files from a list of messages, returning a hash of
29
+ # file_id => local_path for files and attachment index => local_path for attachments.
30
+ def download_message_files(messages, workspace)
31
+ files_dir = ensure_workspace_dir(workspace.name)
32
+ file_paths = {}
33
+
34
+ messages.each do |message|
35
+ download_files(message.files, files_dir, workspace, file_paths)
36
+ download_attachment_images(message.attachments, message.ts, files_dir, file_paths)
37
+ end
38
+
39
+ file_paths
40
+ end
41
+
42
+ private
43
+
44
+ def ensure_workspace_dir(workspace_name)
45
+ dir = File.join(@cache_dir, 'files', workspace_name)
46
+ FileUtils.mkdir_p(dir)
47
+ dir
48
+ end
49
+
50
+ def download_files(files, dir, workspace, paths)
51
+ files.each do |file|
52
+ path = download_single_file(file, dir, workspace)
53
+ paths[file['id']] = path if path
54
+ end
55
+ end
56
+
57
+ def download_single_file(file, dir, workspace)
58
+ file_id = file['id']
59
+ url = file['url_private_download']
60
+ return unless file_id && url
61
+
62
+ name = file['name'] || 'file'
63
+ local_path = File.join(dir, "#{file_id}_#{sanitize_filename(name)}")
64
+
65
+ return local_path if cached?(local_path, name)
66
+
67
+ download_authed(url, local_path, workspace) ? local_path : nil
68
+ end
69
+
70
+ def download_attachment_images(attachments, message_ts, dir, paths)
71
+ attachments.each_with_index do |att, idx|
72
+ path = download_single_attachment(att, message_ts, idx, dir)
73
+ paths["att_#{message_ts}_#{idx}"] = path if path
74
+ end
75
+ end
76
+
77
+ def download_single_attachment(att, message_ts, idx, dir)
78
+ url = att['image_url'] || att['thumb_url']
79
+ return unless url && downloadable_image_url?(url)
80
+
81
+ local_path = attachment_path(dir, message_ts, idx, url)
82
+ return local_path if cached?(local_path, 'attachment image')
83
+
84
+ download_public(url, local_path) ? local_path : nil
85
+ rescue URI::InvalidURIError
86
+ nil
87
+ end
88
+
89
+ def attachment_path(dir, message_ts, idx, url)
90
+ ext = File.extname(URI.parse(url).path)
91
+ ext = '.jpg' if ext.empty?
92
+ File.join(dir, "att_#{message_ts}_#{idx}#{ext}")
93
+ end
94
+
95
+ def cached?(local_path, label)
96
+ return false unless File.exist?(local_path)
97
+
98
+ @on_debug&.call("Skipping #{label} (cached)")
99
+ true
100
+ end
101
+
102
+ def downloadable_image_url?(url)
103
+ ext = File.extname(URI.parse(url).path).delete('.').downcase
104
+ IMAGE_TYPES.include?(ext) || url.include?('/giphy') || url.include?('tenor.com')
105
+ rescue URI::InvalidURIError
106
+ false
107
+ end
108
+
109
+ def download_authed(url, filepath, workspace)
110
+ uri = URI.parse(url)
111
+ request = Net::HTTP::Get.new(uri)
112
+ apply_workspace_headers(request, workspace)
113
+ write_response(build_http_client(uri).request(request), filepath)
114
+ rescue *NETWORK_ERRORS, SystemCallError => e
115
+ @on_debug&.call("Failed to download file: #{e.message}")
116
+ false
117
+ end
118
+
119
+ def apply_workspace_headers(request, workspace)
120
+ request['Authorization'] = workspace.headers['Authorization']
121
+ request['Cookie'] = workspace.headers['Cookie'] if workspace.headers['Cookie']
122
+ end
123
+
124
+ def download_public(url, filepath)
125
+ response = fetch_with_redirect(url)
126
+ write_response(response, filepath)
127
+ rescue *NETWORK_ERRORS, SystemCallError => e
128
+ @on_debug&.call("Failed to download attachment image: #{e.message}")
129
+ false
130
+ end
131
+
132
+ def fetch_with_redirect(url)
133
+ uri = URI.parse(url)
134
+ MAX_REDIRECTS.times do
135
+ response = build_http_client(uri).request(Net::HTTP::Get.new(uri))
136
+ return response unless response.is_a?(Net::HTTPRedirection) && response['location']
137
+
138
+ uri = resolve_redirect(uri, response['location'])
139
+ return response unless uri.host
140
+ end
141
+ # Exhausted redirects — return last response as-is
142
+ build_http_client(uri).request(Net::HTTP::Get.new(uri))
143
+ end
144
+
145
+ def resolve_redirect(original_uri, location)
146
+ parsed = URI.parse(location)
147
+ return parsed if parsed.host
148
+
149
+ # Relative redirect — resolve against original URI
150
+ URI.parse("#{original_uri.scheme}://#{original_uri.host}:#{original_uri.port}#{location}")
151
+ end
152
+
153
+ def write_response(response, filepath) # rubocop:disable Naming/PredicateMethod
154
+ return false unless response.is_a?(Net::HTTPSuccess)
155
+
156
+ File.binwrite(filepath, response.body)
157
+ @on_debug&.call("Downloaded #{File.basename(filepath)} (#{response.body.bytesize} bytes)")
158
+ true
159
+ end
160
+
161
+ def build_http_client(uri)
162
+ http = Net::HTTP.new(uri.host, uri.port)
163
+ http.use_ssl = uri.scheme == 'https'
164
+ if http.use_ssl?
165
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
166
+ http.cert_store = OpenSSL::X509::Store.new
167
+ http.cert_store.set_default_paths
168
+ end
169
+ http.open_timeout = 10
170
+ http.read_timeout = 30
171
+ http
172
+ end
173
+
174
+ def sanitize_filename(name)
175
+ name.gsub(%r{[/\\:*?"<>|]}, '_')
176
+ end
177
+ end
178
+ # rubocop:enable Metrics/ClassLength
179
+ end
180
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Read-through wrapper around CacheStore#get_meta/#set_meta with optional
6
+ # TTL and refresh override. Used by ProfileResolver and similar services.
7
+ module MetaCache
8
+ module_function
9
+
10
+ def fetch(cache_store, workspace_name, key, ttl: nil, refresh: false)
11
+ cached = read(cache_store, workspace_name, key, ttl: ttl) unless refresh
12
+ return cached if cached
13
+
14
+ value = yield
15
+ write(cache_store, workspace_name, key, value)
16
+ value
17
+ end
18
+
19
+ def read(cache_store, workspace_name, key, ttl: nil)
20
+ return nil unless cache_store && workspace_name
21
+
22
+ cache_store.get_meta(workspace_name, key, ttl: ttl)
23
+ end
24
+
25
+ def write(cache_store, workspace_name, key, value)
26
+ return unless cache_store && workspace_name && value
27
+
28
+ cache_store.set_meta(workspace_name, key, value)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Builds Models::Profile from raw users.profile.get + users.info +
6
+ # team.profile.get responses. Pure data assembly — no API calls.
7
+ module ProfileBuilder
8
+ module_function
9
+
10
+ # rubocop:disable Metrics/MethodLength
11
+ def build(profile_response:, info_response: nil, schema_response: nil, workspace_team_id: nil)
12
+ info_data = extract_info(info_response)
13
+ profile_data = extract_profile(profile_response, info_data)
14
+ schema = extract_schema(schema_response)
15
+ team_id = info_data['team_id'] || profile_data['team']
16
+
17
+ Models::Profile.new(
18
+ **identity(profile_data, info_data),
19
+ **status(profile_data),
20
+ **tz(info_data),
21
+ **flags(info_data, team_id, workspace_team_id),
22
+ team_id: team_id,
23
+ home_team_name: nil,
24
+ presence: nil,
25
+ sections: schema[:sections],
26
+ custom_fields: build_fields(profile_data['fields'] || {}, schema[:fields_by_id]),
27
+ resolved_users: {}
28
+ )
29
+ end
30
+ # rubocop:enable Metrics/MethodLength
31
+
32
+ # Slack Connect external users often fail users.profile.get with
33
+ # user_not_found, but users.info still returns the same fields nested at
34
+ # user.profile. Fall back to that so we render a useful card.
35
+ def extract_profile(response, info_data = {})
36
+ primary = response.is_a?(Hash) ? (response['profile'] || {}) : {}
37
+ return primary unless primary.empty?
38
+
39
+ info_data['profile'] || {}
40
+ end
41
+
42
+ def extract_info(response)
43
+ return {} unless response.is_a?(Hash)
44
+
45
+ response['user'] || {}
46
+ end
47
+
48
+ def extract_schema(response)
49
+ section = response.is_a?(Hash) ? (response['profile'] || {}) : {}
50
+ fields = section['fields'] || []
51
+ {
52
+ fields_by_id: fields.to_h { |f| [f['id'], f] },
53
+ sections: section['sections'] || []
54
+ }
55
+ end
56
+
57
+ def identity(profile_data, info_data)
58
+ names(profile_data, info_data).merge(contact(profile_data))
59
+ end
60
+
61
+ def names(profile_data, info_data)
62
+ {
63
+ user_id: info_data['id'] || profile_data['id'] || '',
64
+ real_name: profile_data['real_name'] || info_data['real_name'],
65
+ display_name: profile_data['display_name'],
66
+ first_name: profile_data['first_name'],
67
+ last_name: profile_data['last_name']
68
+ }
69
+ end
70
+
71
+ def contact(profile_data)
72
+ {
73
+ title: profile_data['title'],
74
+ email: profile_data['email'],
75
+ phone: profile_data['phone'],
76
+ pronouns: profile_data['pronouns'],
77
+ image_url: profile_data['image_512'] || profile_data['image_192'] || profile_data['image_72'],
78
+ start_date: profile_data['start_date']
79
+ }
80
+ end
81
+
82
+ def status(profile_data)
83
+ {
84
+ status_text: profile_data['status_text'] || '',
85
+ status_emoji: profile_data['status_emoji'] || '',
86
+ status_expiration: profile_data['status_expiration'] || 0
87
+ }
88
+ end
89
+
90
+ def tz(info_data)
91
+ {
92
+ tz: info_data['tz'],
93
+ tz_label: info_data['tz_label'],
94
+ tz_offset: info_data['tz_offset'] || 0
95
+ }
96
+ end
97
+
98
+ def flags(info_data, team_id, workspace_team_id)
99
+ {
100
+ is_admin: info_data['is_admin'] || false,
101
+ is_owner: info_data['is_owner'] || false,
102
+ is_bot: info_data['is_bot'] || false,
103
+ is_external: external?(team_id, workspace_team_id),
104
+ deleted: info_data['deleted'] == true
105
+ }
106
+ end
107
+
108
+ def external?(team_id, workspace_team_id)
109
+ !!(workspace_team_id && team_id && team_id != workspace_team_id)
110
+ end
111
+
112
+ def build_fields(profile_fields, schema_fields_by_id)
113
+ profile_fields.map do |field_id, field_data|
114
+ build_field(field_id, field_data, schema_fields_by_id[field_id] || {})
115
+ end
116
+ end
117
+
118
+ def build_field(field_id, field_data, schema)
119
+ Models::ProfileField.new(
120
+ id: field_id, label: field_data['label'] || schema['label'] || field_id,
121
+ value: field_data['value'].to_s, alt: field_data['alt'].to_s,
122
+ type: schema['type'] || 'text', ordering: schema['ordering'].to_i,
123
+ section_id: schema['section_id'],
124
+ hidden: schema['is_hidden'] == true, inverse: schema['is_inverse'] == true
125
+ )
126
+ end
127
+ end
128
+ end
129
+ end
@@ -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