slk 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/README.md +31 -0
- data/lib/slk/api/custom_status.rb +112 -0
- data/lib/slk/api/team.rb +24 -0
- data/lib/slk/api/users.rb +10 -0
- data/lib/slk/cli.rb +14 -3
- data/lib/slk/commands/base.rb +13 -1
- data/lib/slk/commands/debug.rb +110 -0
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/org.rb +119 -0
- data/lib/slk/commands/status.rb +224 -13
- data/lib/slk/commands/who.rb +115 -0
- data/lib/slk/formatters/output.rb +2 -0
- data/lib/slk/formatters/profile_field_renderer.rb +107 -0
- data/lib/slk/formatters/profile_formatter.rb +87 -0
- data/lib/slk/formatters/profile_rows.rb +72 -0
- data/lib/slk/models/profile.rb +71 -0
- data/lib/slk/models/profile_field.rb +29 -0
- data/lib/slk/models/scheduled_status.rb +77 -0
- data/lib/slk/runner.rb +21 -0
- data/lib/slk/services/api_client.rb +75 -10
- data/lib/slk/services/cache_store.rb +46 -2
- data/lib/slk/services/encryption.rb +8 -1
- data/lib/slk/services/meta_cache.rb +32 -0
- data/lib/slk/services/profile_builder.rb +129 -0
- data/lib/slk/services/profile_resolver.rb +138 -0
- data/lib/slk/services/user_lookup.rb +9 -13
- data/lib/slk/services/user_matcher.rb +64 -0
- data/lib/slk/services/user_picker.rb +68 -0
- data/lib/slk/services/who_target_resolver.rb +64 -0
- data/lib/slk/support/time_parser.rb +120 -0
- data/lib/slk/support/time_range_parser.rb +166 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +59 -1
- metadata +21 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# Composite model representing a Slack user profile, merged from
|
|
6
|
+
# users.profile.get + users.info + team.profile.get schema.
|
|
7
|
+
#
|
|
8
|
+
# custom_fields is an Array<ProfileField>. resolved_users is a mutable
|
|
9
|
+
# Hash<user_id, Profile> populated by ProfileResolver one level deep
|
|
10
|
+
# for type:user fields (e.g. Supervisor).
|
|
11
|
+
Profile = Data.define(
|
|
12
|
+
:user_id, :real_name, :display_name, :first_name, :last_name,
|
|
13
|
+
:title, :email, :phone, :pronouns, :image_url,
|
|
14
|
+
:status_text, :status_emoji, :status_expiration,
|
|
15
|
+
:tz, :tz_label, :tz_offset, :start_date,
|
|
16
|
+
:is_admin, :is_owner, :is_bot, :is_external, :deleted,
|
|
17
|
+
:team_id, :home_team_name,
|
|
18
|
+
:presence, :sections, :custom_fields, :resolved_users
|
|
19
|
+
) do
|
|
20
|
+
def presence_label
|
|
21
|
+
case presence
|
|
22
|
+
when 'active' then 'Active'
|
|
23
|
+
when 'away' then 'Away'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def best_name
|
|
28
|
+
return display_name unless display_name.to_s.empty?
|
|
29
|
+
return real_name unless real_name.to_s.empty?
|
|
30
|
+
|
|
31
|
+
user_id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def external?
|
|
35
|
+
is_external
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Custom fields with values, ordered by ordering then label.
|
|
39
|
+
# Hidden fields are filtered unless explicitly requested.
|
|
40
|
+
def visible_fields
|
|
41
|
+
custom_fields
|
|
42
|
+
.reject { |f| f.empty? || f.hidden }
|
|
43
|
+
.sort_by { |f| [f.ordering.to_i, f.label.to_s] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def people_fields
|
|
47
|
+
visible_fields.select { |f| f.type == 'user' }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def fields_in_section(section_id)
|
|
51
|
+
visible_fields.select { |f| f.section_id == section_id }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def section(section_id)
|
|
55
|
+
sections.find { |s| s['id'] == section_id }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# User IDs from the first non-inverse Supervisor-like field.
|
|
59
|
+
# Prefers a field literally labeled "Supervisor".
|
|
60
|
+
def supervisor_ids
|
|
61
|
+
preferred = people_fields.find { |f| f.label.to_s.casecmp('Supervisor').zero? && !f.inverse }
|
|
62
|
+
preferred ||= people_fields.find { |f| !f.inverse }
|
|
63
|
+
preferred ? preferred.user_ids : []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reports_field
|
|
67
|
+
custom_fields.find { |f| f.type == 'user' && f.inverse }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# A single custom profile field from users.profile.get + team.profile.get schema.
|
|
6
|
+
#
|
|
7
|
+
# `value` is the raw string from Slack: a date as YYYY-MM-DD for type:date,
|
|
8
|
+
# a comma-separated list of user IDs for type:user, a URL for type:link.
|
|
9
|
+
# `alt` is Slack's optional display label (e.g. link text).
|
|
10
|
+
ProfileField = Data.define(
|
|
11
|
+
:id, :label, :value, :alt, :type, :ordering, :section_id, :hidden, :inverse
|
|
12
|
+
) do
|
|
13
|
+
def empty?
|
|
14
|
+
value.to_s.empty?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# type:user values can be multi-value (comma-separated user IDs).
|
|
18
|
+
def user_ids
|
|
19
|
+
return [] unless type == 'user'
|
|
20
|
+
|
|
21
|
+
value.to_s.split(',').map(&:strip).reject(&:empty?)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def link_text
|
|
25
|
+
alt.to_s.empty? ? value.to_s : alt.to_s
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# A status queued to turn on later, from users.customStatus.list.
|
|
6
|
+
#
|
|
7
|
+
# Slack derives `duration` server-side from the window, so it is not
|
|
8
|
+
# modelled here — the window itself is the source of truth.
|
|
9
|
+
ScheduledStatus = Data.define(:id, :text, :emoji, :date_scheduled, :date_expire, :dnd, :active) do
|
|
10
|
+
def self.from_api(data)
|
|
11
|
+
validate!(data)
|
|
12
|
+
|
|
13
|
+
new(
|
|
14
|
+
id: data['id'].to_s,
|
|
15
|
+
text: data['text'].to_s,
|
|
16
|
+
emoji: data['emoji'].to_s,
|
|
17
|
+
date_scheduled: data['date_scheduled'].to_i,
|
|
18
|
+
date_expire: data['date_expire'].to_i,
|
|
19
|
+
dnd: truthy?(data['is_dnd']),
|
|
20
|
+
active: truthy?(data['is_active'])
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every field is coerced, so without this guard any payload at all —
|
|
25
|
+
# a bare string, nil, a hash of unexpected keys — becomes a valid-looking
|
|
26
|
+
# record with an empty id that prints as a blank line and never matches
|
|
27
|
+
# the id the user asked about. An unusable record is a protocol change,
|
|
28
|
+
# not a status.
|
|
29
|
+
def self.validate!(data)
|
|
30
|
+
return if data.is_a?(Hash) && !data['id'].to_s.empty?
|
|
31
|
+
|
|
32
|
+
raise ApiError.new("Slack returned a scheduled status with no id: #{data.inspect[0, 120]}",
|
|
33
|
+
code: :malformed_scheduled_status)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :validate!
|
|
36
|
+
|
|
37
|
+
# These endpoints are string-typed on the way in — Api::CustomStatus sends
|
|
38
|
+
# is_dnd as 'true' — so a strict `== true` would quietly read a scheduled
|
|
39
|
+
# DND back as off. Accept the shapes Slack actually uses.
|
|
40
|
+
def self.truthy?(value) = [true, 'true', 1, '1'].include?(value)
|
|
41
|
+
private_class_method :truthy?
|
|
42
|
+
|
|
43
|
+
def starts_at = date_scheduled.positive? ? Time.at(date_scheduled) : nil
|
|
44
|
+
def ends_at = date_expire.positive? ? Time.at(date_expire) : nil
|
|
45
|
+
|
|
46
|
+
def to_s
|
|
47
|
+
span = window
|
|
48
|
+
[
|
|
49
|
+
emoji,
|
|
50
|
+
text,
|
|
51
|
+
span.empty? ? '' : "(#{span})",
|
|
52
|
+
('[dnd]' if dnd),
|
|
53
|
+
# Slack marks the one that has already turned on. Worth showing: it
|
|
54
|
+
# is the difference between "will happen" and "is happening".
|
|
55
|
+
('[active]' if active)
|
|
56
|
+
].reject { |part| part.to_s.empty? }.join(' ')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Human-readable window; the end date is only repeated when it differs
|
|
60
|
+
# from the start date, which makes multi-day windows obvious.
|
|
61
|
+
def window
|
|
62
|
+
dated = '%a %b %-d %-l:%M%P'
|
|
63
|
+
start_time = starts_at or return ''
|
|
64
|
+
formatted = start_time.strftime(dated)
|
|
65
|
+
finish = ends_at or return formatted
|
|
66
|
+
|
|
67
|
+
"#{formatted} -> #{finish.strftime(same_day?(start_time, finish) ? '%-l:%M%P' : dated)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def same_day?(start_time, finish)
|
|
73
|
+
start_time.to_date == finish.to_date
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/slk/runner.rb
CHANGED
|
@@ -88,6 +88,27 @@ module Slk
|
|
|
88
88
|
Api::Saved.new(@api_client, workspace(workspace_name))
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def custom_status_api(workspace_name = nil)
|
|
92
|
+
Api::CustomStatus.new(@api_client, workspace(workspace_name))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def team_api(workspace_name = nil)
|
|
96
|
+
Api::Team.new(@api_client, workspace(workspace_name))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def profile_resolver(workspace_name = nil, refresh: false)
|
|
100
|
+
ws = workspace(workspace_name)
|
|
101
|
+
resolver = Services::ProfileResolver.new(
|
|
102
|
+
users_api: users_api(workspace_name),
|
|
103
|
+
team_api: team_api(workspace_name),
|
|
104
|
+
cache_store: @cache_store,
|
|
105
|
+
workspace_name: ws.name,
|
|
106
|
+
on_debug: ->(msg) { @output.debug(msg) }
|
|
107
|
+
)
|
|
108
|
+
resolver.refresh = refresh
|
|
109
|
+
resolver
|
|
110
|
+
end
|
|
111
|
+
|
|
91
112
|
def message_resolver(workspace_name = nil)
|
|
92
113
|
Services::MessageResolver.new(
|
|
93
114
|
conversations_api: conversations_api(workspace_name),
|
|
@@ -29,6 +29,7 @@ module Slk
|
|
|
29
29
|
@on_request_body = nil
|
|
30
30
|
@on_response_body = nil
|
|
31
31
|
@http_cache = {}
|
|
32
|
+
@rate_resets = {}
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# Close all cached HTTP connections
|
|
@@ -75,15 +76,65 @@ module Slk
|
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
def execute_request(method, query_params = nil, body: nil, &)
|
|
79
|
+
attempt_request(method, query_params, body: body, retried: false, &)
|
|
80
|
+
rescue *NETWORK_ERRORS => e
|
|
81
|
+
raise ApiError.new("Network error: #{e.message}", code: :network_error)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def attempt_request(method, query_params, body:, retried:, &)
|
|
85
|
+
send_one_request(method, query_params, body: body, &)
|
|
86
|
+
rescue RateLimitError => e
|
|
87
|
+
wait = wait_for(e)
|
|
88
|
+
raise e if retried || wait.nil?
|
|
89
|
+
|
|
90
|
+
announce_wait(method, wait)
|
|
91
|
+
sleep(wait)
|
|
92
|
+
attempt_request(method, query_params, body: body, retried: true, &)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def send_one_request(method, query_params, body:, &)
|
|
96
|
+
await_reset(method)
|
|
78
97
|
log_request(method)
|
|
79
98
|
log_request_body(method, body)
|
|
80
99
|
uri = build_uri(method, query_params)
|
|
81
100
|
response, elapsed_ms = timed_request(uri, &)
|
|
101
|
+
track_rate_limit(method, response)
|
|
82
102
|
log_response(method, response, elapsed_ms)
|
|
83
103
|
log_response_body(method, response.body)
|
|
84
104
|
handle_response(response, method)
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def wait_for(error)
|
|
108
|
+
max = ENV.fetch('SLK_MAX_RETRY_AFTER', '60').to_i
|
|
109
|
+
seconds = error.retry_after || ENV.fetch('SLK_DEFAULT_RETRY_AFTER', '30').to_i
|
|
110
|
+
return nil unless seconds.positive? && seconds <= max
|
|
111
|
+
|
|
112
|
+
seconds
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def announce_wait(method, seconds)
|
|
116
|
+
@on_response&.call(method, 'rate-wait', { 'sleep_seconds' => seconds })
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Predictive throttle: when X-RateLimit-Remaining hits 0, sleep until
|
|
120
|
+
# X-RateLimit-Reset before issuing the next call to that method.
|
|
121
|
+
def track_rate_limit(method, response)
|
|
122
|
+
remaining = response['X-RateLimit-Remaining']&.to_i
|
|
123
|
+
reset = response['X-RateLimit-Reset']&.to_i
|
|
124
|
+
@rate_resets[method] = reset if remaining&.zero? && reset&.positive?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def await_reset(method)
|
|
128
|
+
reset = @rate_resets[method] or return
|
|
129
|
+
wait = reset - Time.now.to_i
|
|
130
|
+
return @rate_resets.delete(method) if wait <= 0
|
|
131
|
+
|
|
132
|
+
max = ENV.fetch('SLK_MAX_RETRY_AFTER', '60').to_i
|
|
133
|
+
return if wait > max
|
|
134
|
+
|
|
135
|
+
announce_wait(method, wait)
|
|
136
|
+
sleep(wait)
|
|
137
|
+
@rate_resets.delete(method)
|
|
87
138
|
end
|
|
88
139
|
|
|
89
140
|
def build_uri(method, query_params)
|
|
@@ -167,26 +218,40 @@ module Slk
|
|
|
167
218
|
def handle_response(response, _method)
|
|
168
219
|
case response
|
|
169
220
|
when Net::HTTPSuccess then parse_success_response(response)
|
|
170
|
-
when Net::HTTPUnauthorized
|
|
221
|
+
when Net::HTTPUnauthorized
|
|
222
|
+
raise ApiError.new('Invalid token or session expired', code: :unauthorized)
|
|
171
223
|
when Net::HTTPTooManyRequests then handle_rate_limit(response)
|
|
172
|
-
else raise ApiError
|
|
224
|
+
else raise ApiError.new("HTTP #{response.code}: #{response.message}", code: :http_error)
|
|
173
225
|
end
|
|
174
226
|
end
|
|
175
227
|
|
|
176
228
|
def handle_rate_limit(response)
|
|
177
|
-
retry_after = response['Retry-After']
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
raise ApiError, 'Rate limited - please wait and try again'
|
|
229
|
+
retry_after = response['Retry-After']&.to_i
|
|
230
|
+
message = retry_after ? "Rate limited — retry after #{retry_after}s" : 'Rate limited — please wait'
|
|
231
|
+
raise RateLimitError.new(message, retry_after: retry_after)
|
|
181
232
|
end
|
|
182
233
|
|
|
183
234
|
def parse_success_response(response)
|
|
184
235
|
result = JSON.parse(response.body)
|
|
185
|
-
|
|
236
|
+
raise_rate_limit(response) if result['error'] == 'ratelimited'
|
|
237
|
+
unless result['ok']
|
|
238
|
+
message = result['error'] || 'Unknown error'
|
|
239
|
+
raise ApiError.new(message, code: message.to_sym)
|
|
240
|
+
end
|
|
186
241
|
|
|
187
242
|
result
|
|
188
243
|
rescue JSON::ParserError
|
|
189
|
-
raise ApiError
|
|
244
|
+
raise ApiError.new('Invalid JSON response from Slack API', code: :invalid_json)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def raise_rate_limit(response)
|
|
248
|
+
retry_after = response['Retry-After']&.to_i
|
|
249
|
+
message = if retry_after
|
|
250
|
+
"Rate limited by Slack — retry after #{retry_after}s"
|
|
251
|
+
else
|
|
252
|
+
'Rate limited by Slack — wait a minute and try again'
|
|
253
|
+
end
|
|
254
|
+
raise RateLimitError.new(message, retry_after: retry_after)
|
|
190
255
|
end
|
|
191
256
|
end
|
|
192
257
|
# rubocop:enable Metrics/ClassLength
|
|
@@ -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
|
|
|
@@ -104,8 +104,15 @@ module Slk
|
|
|
104
104
|
'Please provide the correct public key for this private key.'
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# `-P ''` supplies the passphrase up front so ssh-keygen cannot ask for
|
|
108
|
+
# one. Without it, an encrypted key makes ssh-keygen prompt — and it
|
|
109
|
+
# prompts on the console directly, not on the stdin capture3 hands it, so
|
|
110
|
+
# closing stdin does not help. On Windows that read blocks forever; this
|
|
111
|
+
# hung the CI job until its timeout with no output. An empty passphrase
|
|
112
|
+
# fails an encrypted key with the same message it already reported, which
|
|
113
|
+
# is the right answer regardless: slk cannot use such a key anyway.
|
|
107
114
|
def derive_public_key(private_key_path)
|
|
108
|
-
output, error, status = Open3.capture3('ssh-keygen', '-y', '-f', private_key_path)
|
|
115
|
+
output, error, status = Open3.capture3('ssh-keygen', '-y', '-P', '', '-f', private_key_path)
|
|
109
116
|
return output.strip if status.success?
|
|
110
117
|
|
|
111
118
|
# Check if ssh-keygen is missing vs other failures
|
|
@@ -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
|