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.
- 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/base.rb +5 -2
- data/lib/slk/commands/debug.rb +110 -0
- data/lib/slk/commands/messages.rb +44 -6
- data/lib/slk/commands/org.rb +119 -0
- data/lib/slk/commands/thread.rb +11 -6
- data/lib/slk/commands/who.rb +115 -0
- data/lib/slk/formatters/attachment_formatter.rb +22 -7
- data/lib/slk/formatters/message_formatter.rb +13 -4
- 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/file_downloader.rb +180 -0
- 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 +45 -1
- metadata +18 -2
|
@@ -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]
|
|
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]
|
|
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
|
-
|
|
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
|