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
|
@@ -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.
|
|
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,
|
|
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,
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
89
|
-
@output.blue("[File: #{
|
|
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
|
|
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)
|
|
@@ -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
|
-
|
|
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
|