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.
@@ -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
- rescue *NETWORK_ERRORS => e
86
- raise ApiError, "Network error: #{e.message}"
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 then raise ApiError, 'Invalid token or session expired'
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, "HTTP #{response.code}: #{response.message}"
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
- raise ApiError, "Rate limited - retry after #{retry_after} seconds" if retry_after
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
- raise ApiError, result['error'] || 'Unknown error' unless result['ok']
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, 'Invalid JSON response from Slack API'
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]&.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
 
@@ -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