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.
@@ -9,17 +9,19 @@ module Slk
9
9
  @text_processor = text_processor
10
10
  end
11
11
 
12
- def format(attachments, lines, options)
12
+ def format(attachments, lines, options, message_ts: nil)
13
13
  return if attachments.empty?
14
14
  return if options[:no_attachments]
15
15
 
16
- attachments.each { |att| format_attachment(att, lines, options) }
16
+ attachments.each_with_index do |att, idx|
17
+ format_attachment(att, lines, options, message_ts: message_ts, index: idx)
18
+ end
17
19
  end
18
20
 
19
21
  private
20
22
 
21
23
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
22
- def format_attachment(attachment, lines, options)
24
+ def format_attachment(attachment, lines, options, message_ts: nil, index: 0)
23
25
  att_text = attachment['text'] || attachment['fallback']
24
26
  image_url = attachment['image_url'] || attachment['thumb_url']
25
27
  block_images = extract_block_images(attachment)
@@ -29,7 +31,7 @@ module Slk
29
31
  lines << ''
30
32
  format_author(attachment, lines)
31
33
  format_text(att_text, lines, options) if att_text && block_images.empty?
32
- format_image(attachment, image_url, lines) if image_url
34
+ format_image(attachment, lines, options, message_ts: message_ts, index: index) if image_url
33
35
  block_images.each { |img| lines << "> [Image: #{img}]" }
34
36
  end
35
37
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -64,9 +66,22 @@ module Slk
64
66
  Support::TextWrapper.wrap(text, width - 2, width - 2)
65
67
  end
66
68
 
67
- def format_image(attachment, image_url, lines)
68
- filename = attachment['title'] || extract_filename(image_url)
69
- lines << "> [Image: #{filename}]"
69
+ def format_image(attachment, lines, options, message_ts: nil, index: 0)
70
+ local_path = lookup_attachment_path(options, message_ts, index)
71
+ if local_path
72
+ lines << "> [Image: #{local_path}]"
73
+ else
74
+ image_url = attachment['image_url'] || attachment['thumb_url']
75
+ filename = attachment['title'] || extract_filename(image_url)
76
+ lines << "> [Image: #{filename}]"
77
+ end
78
+ end
79
+
80
+ def lookup_attachment_path(options, message_ts, index)
81
+ return nil unless message_ts
82
+
83
+ key = "att_#{message_ts}_#{index}"
84
+ options.dig(:file_paths, key)
70
85
  end
71
86
 
72
87
  def extract_filename(url)
@@ -85,8 +85,14 @@ module Slk
85
85
  return '' unless message.files? && !options[:no_files]
86
86
 
87
87
  first_file = message.files.first
88
- file_name = first_file['name'] || 'file'
89
- @output.blue("[File: #{file_name}]")
88
+ file_label = file_display_label(first_file, options)
89
+ @output.blue("[File: #{file_label}]")
90
+ end
91
+
92
+ def file_display_label(file, options)
93
+ file_id = file['id']
94
+ local_path = options.dig(:file_paths, file_id) if file_id
95
+ local_path || file['name'] || 'file'
90
96
  end
91
97
 
92
98
  def build_output_lines(main_line, message, workspace, options, display_text)
@@ -96,7 +102,7 @@ module Slk
96
102
  BlockFormatter.new(text_processor: text_processor)
97
103
  .format(message.blocks, message.text, lines, options)
98
104
  AttachmentFormatter.new(output: @output, text_processor: text_processor)
99
- .format(message.attachments, lines, options)
105
+ .format(message.attachments, lines, options, message_ts: message.ts)
100
106
  format_files(message, lines, options, skip_first: display_text.include?('[File:'))
101
107
  format_reactions(message, lines, workspace, options)
102
108
  format_thread_indicator(message, lines, options)
@@ -128,7 +134,10 @@ module Slk
128
134
  return if options[:no_files]
129
135
 
130
136
  files = files_to_display(message.files, skip_first)
131
- files.each { |file| lines << @output.blue("[File: #{file['name'] || 'file'}]") }
137
+ files.each do |file|
138
+ label = file_display_label(file, options)
139
+ lines << @output.blue("[File: #{label}]")
140
+ end
132
141
  end
133
142
 
134
143
  def files_to_display(files, skip_first)
@@ -18,6 +18,8 @@ module Slk
18
18
 
19
19
  attr_reader :verbose, :quiet
20
20
 
21
+ def color? = @color
22
+
21
23
  def initialize(io: $stdout, err: $stderr, color: nil, verbose: false, quiet: false)
22
24
  @io = io
23
25
  @err = err
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders ProfileField values to display strings (date with relative
6
+ # phrasing, link with alt text, type:user dereferenced via resolved_users).
7
+ class ProfileFieldRenderer
8
+ def initialize(output:)
9
+ @output = output
10
+ @hyperlinks = output.respond_to?(:color?) && output.color?
11
+ end
12
+
13
+ def render(field, profile)
14
+ case field.type
15
+ when 'date' then format_date(field.value)
16
+ when 'link' then format_link(field)
17
+ when 'user' then format_user_list(field, profile)
18
+ else format_text(field.value)
19
+ end
20
+ end
21
+
22
+ # Slack stores some text fields (notably free-text profile blocks) as
23
+ # rich_text JSON blocks. Detect and flatten to plain text.
24
+ def format_text(value)
25
+ text = value.to_s
26
+ return text unless text.start_with?('[{') && text.include?('rich_text')
27
+
28
+ flatten_rich_text(JSON.parse(text))
29
+ rescue JSON::ParserError
30
+ text
31
+ end
32
+
33
+ def flatten_rich_text(blocks)
34
+ Array(blocks).flat_map { |block| extract_text_from_block(block) }.join
35
+ end
36
+
37
+ def extract_text_from_block(block)
38
+ return [block.to_s] unless block.is_a?(Hash)
39
+
40
+ return [block['text'].to_s] if block['type'] == 'text'
41
+ return [block['name'] ? ":#{block['name']}:" : ''] if block['type'] == 'emoji'
42
+
43
+ Array(block['elements']).flat_map { |el| extract_text_from_block(el) }
44
+ end
45
+
46
+ def render_user_reference(user_id, profile)
47
+ ref = profile.resolved_users[user_id]
48
+ return user_id unless ref
49
+
50
+ suffix = ref.title.to_s.empty? ? '' : " — #{ref.title}"
51
+ pronouns = ref.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{ref.pronouns})")}"
52
+ "#{ref.best_name}#{pronouns}#{suffix}"
53
+ end
54
+
55
+ private
56
+
57
+ def format_user_list(field, profile)
58
+ field.user_ids.map { |uid| render_user_reference(uid, profile) }.join(', ')
59
+ end
60
+
61
+ def format_date(value)
62
+ date = parse_date(value) or return value
63
+ relative = relative_phrase(date, Date.today)
64
+ formatted = date.strftime('%b %-d, %Y')
65
+ relative ? "#{formatted} (#{relative})" : formatted
66
+ end
67
+
68
+ def parse_date(value)
69
+ Date.parse(value.to_s)
70
+ rescue ArgumentError, TypeError
71
+ nil
72
+ end
73
+
74
+ def relative_phrase(then_date, now_date)
75
+ return nil if then_date >= now_date
76
+
77
+ years, months = years_months_between(then_date, now_date)
78
+ parts = []
79
+ parts << "#{years}y" if years.positive?
80
+ parts << "#{months}mo" if months.positive?
81
+ parts.empty? ? nil : "#{parts.join(' ')} ago"
82
+ end
83
+
84
+ def years_months_between(then_date, now_date)
85
+ months = ((now_date.year - then_date.year) * 12) + (now_date.month - then_date.month)
86
+ months -= 1 if now_date.day < then_date.day
87
+ [months / 12, months % 12]
88
+ end
89
+
90
+ def format_link(field)
91
+ url = field.value.to_s
92
+ label = field.alt.to_s.empty? ? url : field.alt
93
+ return url if label == url
94
+ return "#{label} (#{shorten_url(url)})" unless @hyperlinks
95
+
96
+ # OSC 8 hyperlink — clickable in iTerm2, Ghostty, Wezterm, Kitty, Vte.
97
+ "\e]8;;#{url}\a#{label}\e]8;;\a"
98
+ end
99
+
100
+ def shorten_url(url)
101
+ URI.parse(url).host || url
102
+ rescue URI::InvalidURIError
103
+ url
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders a Models::Profile for `slk who`.
6
+ # Compact mode: teems-style two-column card.
7
+ # Full mode: section-grouped layout matching Slack web UI.
8
+ class ProfileFormatter
9
+ MIN_LABEL_WIDTH = 8
10
+ MAX_LABEL_WIDTH = 20
11
+
12
+ def initialize(output:, emoji_replacer: nil)
13
+ @output = output
14
+ field_renderer = ProfileFieldRenderer.new(output: output)
15
+ @rows = ProfileRows.new(field_renderer: field_renderer, emoji_replacer: emoji_replacer)
16
+ end
17
+
18
+ def compact(profile)
19
+ render_header(profile)
20
+ emit_rows(@rows.compact(profile))
21
+ nil
22
+ end
23
+
24
+ def full(profile)
25
+ render_header(profile)
26
+ return emit_rows(@rows.external(profile)) if profile.external?
27
+
28
+ render_section('Contact information', @rows.contact(profile))
29
+ render_section('People', @rows.people(profile))
30
+ render_section('About me', @rows.about(profile))
31
+ end
32
+
33
+ def emit_rows(rows)
34
+ non_empty = rows.reject { |_, v| v.nil? || v.to_s.empty? }
35
+ width = label_width(non_empty)
36
+ non_empty.each { |label, value| emit_row(label, value, width) }
37
+ end
38
+
39
+ def label_width(rows)
40
+ max = rows.map { |label, _| label_for(label).length }.max || 0
41
+ max.clamp(MIN_LABEL_WIDTH, MAX_LABEL_WIDTH)
42
+ end
43
+
44
+ def label_for(label)
45
+ text = label.to_s
46
+ text.length > MAX_LABEL_WIDTH ? "#{text[0, MAX_LABEL_WIDTH - 1]}…" : text
47
+ end
48
+
49
+ private
50
+
51
+ def render_header(profile)
52
+ @output.puts(@output.bold(profile.best_name) + pronouns_suffix(profile))
53
+ header_tags(profile).each { |tag| @output.puts(" #{tag}") }
54
+ @output.puts
55
+ end
56
+
57
+ def header_tags(profile)
58
+ tags = []
59
+ tags << profile.title unless profile.title.to_s.empty?
60
+ tags << external_tag(profile) if profile.external?
61
+ tags << @output.bold('deactivated account') if profile.deleted
62
+ tags
63
+ end
64
+
65
+ def pronouns_suffix(profile)
66
+ profile.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{profile.pronouns})")}"
67
+ end
68
+
69
+ def external_tag(profile)
70
+ @output.gray("external — #{profile.home_team_name || 'external workspace'}")
71
+ end
72
+
73
+ def render_section(title, rows)
74
+ return if rows.reject { |_, v| v.nil? || v.to_s.empty? }.empty?
75
+
76
+ @output.puts(@output.bold(title))
77
+ emit_rows(rows)
78
+ @output.puts
79
+ end
80
+
81
+ def emit_row(label, value, width)
82
+ padded = label_for(label).ljust(width)
83
+ @output.puts(" #{@output.gray(padded)} #{value}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Builds [label, value] row pairs from a Models::Profile.
6
+ # Pure data — no output, formatting handled by ProfileFormatter.
7
+ class ProfileRows
8
+ def initialize(field_renderer:, emoji_replacer: nil)
9
+ @fields = field_renderer
10
+ @emoji_replacer = emoji_replacer
11
+ end
12
+
13
+ def compact(profile)
14
+ contact(profile) + base(profile) + people(profile) + about(profile, skip_title: true)
15
+ end
16
+
17
+ def contact(profile)
18
+ [['Email', profile.email], ['Phone', profile.phone]]
19
+ end
20
+
21
+ def base(profile)
22
+ [
23
+ ['Presence', profile.presence_label],
24
+ ['Status', status_text(profile)],
25
+ ['Local', local_time(profile)]
26
+ ]
27
+ end
28
+
29
+ def people(profile)
30
+ profile.people_fields.flat_map do |field|
31
+ field.user_ids.map { |uid| [field.label, @fields.render_user_reference(uid, profile)] }
32
+ end
33
+ end
34
+
35
+ def about(profile, skip_title: false)
36
+ fields = profile.visible_fields.reject { |f| f.type == 'user' }
37
+ fields = fields.reject { |f| skip_title && duplicate_title?(f, profile) }
38
+ fields.map { |f| [f.label, @fields.render(f, profile)] }
39
+ end
40
+
41
+ def external(profile)
42
+ contact(profile) + [['Workspace', profile.home_team_name]]
43
+ end
44
+
45
+ private
46
+
47
+ def duplicate_title?(field, profile)
48
+ field.label.casecmp('Title').zero? && field.value.to_s == profile.title.to_s
49
+ end
50
+
51
+ def status_text(profile)
52
+ return nil if profile.status_text.empty? && profile.status_emoji.empty?
53
+
54
+ emoji = render_emoji(profile.status_emoji)
55
+ [emoji, profile.status_text].reject(&:empty?).join(' ')
56
+ end
57
+
58
+ def render_emoji(text)
59
+ return text if text.to_s.empty? || @emoji_replacer.nil?
60
+
61
+ @emoji_replacer.replace(text)
62
+ end
63
+
64
+ def local_time(profile)
65
+ return nil unless profile.tz
66
+
67
+ time_at_user = Time.now.utc + profile.tz_offset.to_i
68
+ "#{time_at_user.strftime('%-l:%M %p')} #{profile.tz_label}".strip
69
+ end
70
+ end
71
+ end
72
+ end
@@ -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
data/lib/slk/runner.rb CHANGED
@@ -88,6 +88,23 @@ module Slk
88
88
  Api::Saved.new(@api_client, workspace(workspace_name))
89
89
  end
90
90
 
91
+ def team_api(workspace_name = nil)
92
+ Api::Team.new(@api_client, workspace(workspace_name))
93
+ end
94
+
95
+ def profile_resolver(workspace_name = nil, refresh: false)
96
+ ws = workspace(workspace_name)
97
+ resolver = Services::ProfileResolver.new(
98
+ users_api: users_api(workspace_name),
99
+ team_api: team_api(workspace_name),
100
+ cache_store: @cache_store,
101
+ workspace_name: ws.name,
102
+ on_debug: ->(msg) { @output.debug(msg) }
103
+ )
104
+ resolver.refresh = refresh
105
+ resolver
106
+ end
107
+
91
108
  def message_resolver(workspace_name = nil)
92
109
  Services::MessageResolver.new(
93
110
  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