slk 0.2.0 → 0.4.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.
@@ -30,6 +30,11 @@ module Slk
30
30
  replace_special_mentions(result)
31
31
  end
32
32
 
33
+ # Public API for looking up user names (used by MessageFormatter)
34
+ def lookup_user_name(workspace, user_id)
35
+ user_lookup_for(workspace).resolve_name(user_id)
36
+ end
37
+
33
38
  private
34
39
 
35
40
  def replace_user_mentions(text, workspace)
@@ -76,42 +81,19 @@ module Slk
76
81
 
77
82
  def lookup_by_type(workspace, id, type)
78
83
  case type
79
- when :user then lookup_user_name(workspace, id)
84
+ when :user then user_lookup_for(workspace).resolve_name(id)
80
85
  when :channel then lookup_channel_name(workspace, id)
81
86
  when :subteam then lookup_subteam_handle(workspace, id)
82
87
  end
83
88
  end
84
89
 
85
- def lookup_user_name(workspace, user_id)
86
- cached = @cache.get_user(workspace.name, user_id)
87
- return cached if cached
88
-
89
- fetch_user_name_from_api(workspace, user_id)
90
- end
91
-
92
- def fetch_user_name_from_api(workspace, user_id)
93
- return nil unless @api
94
-
95
- response = Api::Users.new(@api, workspace).info(user_id)
96
- return nil unless response['ok'] && response['user']
97
-
98
- name = extract_user_display_name(response['user'])
99
- cache_user_name(workspace, user_id, name)
100
- name
101
- rescue ApiError => e
102
- @on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
103
- nil
104
- end
105
-
106
- def cache_user_name(workspace, user_id, name)
107
- @cache.set_user(workspace.name, user_id, name, persist: true) unless name.to_s.empty?
108
- end
109
-
110
- def extract_user_display_name(user)
111
- profile = user['profile'] || {}
112
- profile['display_name'].then { |n| n.to_s.empty? ? nil : n } ||
113
- profile['real_name'].then { |n| n.to_s.empty? ? nil : n } ||
114
- user['name'].then { |n| n.to_s.empty? ? nil : n }
90
+ def user_lookup_for(workspace)
91
+ Services::UserLookup.new(
92
+ cache_store: @cache,
93
+ workspace: workspace,
94
+ api_client: @api,
95
+ on_debug: @on_debug
96
+ )
115
97
  end
116
98
 
117
99
  def lookup_channel_name(workspace, channel_id)
@@ -105,23 +105,16 @@ module Slk
105
105
  return message.user_id if options[:no_names]
106
106
  return message.embedded_username if message.embedded_username
107
107
 
108
- cached = @cache.get_user(workspace.name, message.user_id)
109
- return cached if cached
110
-
111
- lookup_bot_if_needed(message, workspace) || message.user_id
112
- end
113
-
114
- def lookup_bot_if_needed(message, workspace)
115
- return unless message.user_id.start_with?('B') && @api_client
116
-
117
- lookup_bot_name(workspace, message.user_id)
108
+ user_lookup_for(workspace).resolve_name_or_bot(message.user_id) || message.user_id
118
109
  end
119
110
 
120
- def lookup_bot_name(workspace, bot_id)
121
- bots_api = Api::Bots.new(@api_client, workspace, on_debug: @on_debug)
122
- name = bots_api.get_name(bot_id)
123
- @cache.set_user(workspace.name, bot_id, name, persist: true) if name
124
- name
111
+ def user_lookup_for(workspace)
112
+ Services::UserLookup.new(
113
+ cache_store: @cache,
114
+ workspace: workspace,
115
+ api_client: @api_client,
116
+ on_debug: @on_debug
117
+ )
125
118
  end
126
119
 
127
120
  def format_timestamp(time)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Formats search results for terminal display
6
+ class SearchFormatter
7
+ def initialize(output:, emoji_replacer:, mention_replacer:)
8
+ @output = output
9
+ @emoji = emoji_replacer
10
+ @mentions = mention_replacer
11
+ end
12
+
13
+ # Display a list of search results
14
+ def display_all(results, workspace, options: {})
15
+ return @output.puts 'No results found.' if results.empty?
16
+
17
+ results.each_with_index do |result, index|
18
+ display_result(result, workspace, options)
19
+ @output.puts if index < results.length - 1
20
+ end
21
+ end
22
+
23
+ # Display a single search result
24
+ def display_result(result, workspace, options = {})
25
+ timestamp = @output.blue("[#{format_time(result.timestamp)}]")
26
+ channel = @output.cyan(resolve_channel(result, workspace))
27
+ user = @output.bold("#{resolve_user(result, workspace)}:")
28
+ text = prepare_text(result.text, workspace, options)
29
+
30
+ @output.puts "#{timestamp} #{channel} #{user} #{text}"
31
+ display_files(result.files) if result.files&.any?
32
+ end
33
+
34
+ private
35
+
36
+ def resolve_channel(result, workspace)
37
+ if result.dm?
38
+ # For DMs, channel_name is a user ID - resolve it
39
+ @mentions.replace("<@#{result.channel_name}>", workspace)
40
+ else
41
+ "##{result.channel_name}"
42
+ end
43
+ end
44
+
45
+ def resolve_user(result, workspace)
46
+ # If we have a username, use it directly
47
+ return result.username if result.username && !result.username.empty?
48
+
49
+ # Fallback if user_id is also missing
50
+ return 'Unknown User' unless result.user_id && !result.user_id.to_s.empty?
51
+
52
+ # Otherwise resolve the user_id via MentionReplacer
53
+ @mentions.replace("<@#{result.user_id}>", workspace)
54
+ end
55
+
56
+ def prepare_text(text, workspace, options)
57
+ return text if options[:no_emoji] && options[:no_mentions]
58
+
59
+ text = @emoji.replace(text) unless options[:no_emoji]
60
+ text = @mentions.replace(text, workspace) unless options[:no_mentions]
61
+ text
62
+ end
63
+
64
+ def display_files(files)
65
+ files.each do |file|
66
+ @output.puts @output.blue("[Image: #{file[:name]}]")
67
+ end
68
+ end
69
+
70
+ def format_time(time)
71
+ time.strftime('%Y-%m-%d %H:%M')
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # Value object for search.messages API results
6
+ # Search results include channel info inline, unlike regular messages
7
+ SearchResult = Data.define(
8
+ :ts,
9
+ :user_id,
10
+ :username,
11
+ :text,
12
+ :channel_id,
13
+ :channel_name,
14
+ :channel_type,
15
+ :thread_ts,
16
+ :permalink,
17
+ :files
18
+ ) do
19
+ def self.from_api(match)
20
+ channel = match['channel'] || {}
21
+ new(**build_attributes(match, channel))
22
+ end
23
+
24
+ # rubocop:disable Metrics/MethodLength
25
+ def self.build_attributes(match, channel)
26
+ {
27
+ ts: match['ts'],
28
+ user_id: match['user'] || match['username'],
29
+ username: match['username'],
30
+ text: match['text'] || '',
31
+ channel_id: channel['id'],
32
+ channel_name: channel['name'],
33
+ channel_type: determine_channel_type(channel),
34
+ thread_ts: extract_thread_ts(match),
35
+ permalink: match['permalink'],
36
+ files: extract_files(match)
37
+ }
38
+ end
39
+
40
+ def self.extract_files(match)
41
+ files = extract_uploaded_files(match)
42
+ files += extract_attachments(match)
43
+ files
44
+ end
45
+
46
+ def self.extract_uploaded_files(match)
47
+ return [] unless match['files']
48
+
49
+ match['files'].map { |f| { name: f['name'], type: f['filetype'] } }
50
+ end
51
+
52
+ def self.extract_attachments(match)
53
+ return [] unless match['attachments']
54
+
55
+ match['attachments'].flat_map { |a| extract_attachment_images(a) }
56
+ end
57
+
58
+ def self.extract_attachment_images(attachment)
59
+ unless attachment['blocks']
60
+ fallback = attachment['fallback']
61
+ return [] unless fallback
62
+
63
+ return [{ name: fallback, type: 'attachment' }]
64
+ end
65
+
66
+ attachment['blocks'].filter_map do |block|
67
+ next unless block['type'] == 'image'
68
+
69
+ { name: block.dig('title', 'text') || 'Image', type: 'attachment' }
70
+ end
71
+ end
72
+ # rubocop:enable Metrics/MethodLength
73
+
74
+ def self.determine_channel_type(channel)
75
+ return 'im' if channel['is_im']
76
+ return 'mpim' if channel['is_mpim']
77
+
78
+ 'channel'
79
+ end
80
+
81
+ def self.extract_thread_ts(match)
82
+ return nil unless match['permalink']
83
+
84
+ # Extract thread_ts from permalink URL if present
85
+ uri = URI.parse(match['permalink'])
86
+ params = URI.decode_www_form(uri.query || '')
87
+ params.find { |k, _| k == 'thread_ts' }&.last
88
+ rescue URI::InvalidURIError => e
89
+ # Log for debugging - malformed permalinks from API should be rare
90
+ warn "Invalid permalink URI: #{match['permalink']}: #{e.message}" if ENV['DEBUG']
91
+ nil
92
+ end
93
+
94
+ def timestamp
95
+ Time.at(ts.to_f)
96
+ end
97
+
98
+ def thread?
99
+ !thread_ts.nil?
100
+ end
101
+
102
+ def dm?
103
+ %w[im mpim].include?(channel_type)
104
+ end
105
+
106
+ def display_channel
107
+ if dm?
108
+ "@#{channel_name}"
109
+ else
110
+ "##{channel_name}"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/slk/runner.rb CHANGED
@@ -79,6 +79,10 @@ module Slk
79
79
  Api::Activity.new(@api_client, workspace(workspace_name))
80
80
  end
81
81
 
82
+ def search_api(workspace_name = nil)
83
+ Api::Search.new(@api_client, workspace(workspace_name))
84
+ end
85
+
82
86
  # Formatter helpers
83
87
  def message_formatter
84
88
  @message_formatter ||= Formatters::MessageFormatter.new(
@@ -107,6 +111,14 @@ module Slk
107
111
  @duration_formatter ||= Formatters::DurationFormatter.new
108
112
  end
109
113
 
114
+ def search_formatter
115
+ @search_formatter ||= Formatters::SearchFormatter.new(
116
+ output: @output,
117
+ emoji_replacer: emoji_replacer,
118
+ mention_replacer: mention_replacer
119
+ )
120
+ end
121
+
110
122
  # Logging
111
123
  def log_error(error)
112
124
  Support::ErrorLogger.log(error)
@@ -20,11 +20,14 @@ module Slk
20
20
  ].freeze
21
21
 
22
22
  attr_reader :call_count
23
- attr_accessor :on_request
23
+ attr_accessor :on_request, :on_response, :on_request_body, :on_response_body
24
24
 
25
25
  def initialize
26
26
  @call_count = 0
27
27
  @on_request = nil
28
+ @on_response = nil
29
+ @on_request_body = nil
30
+ @on_response_body = nil
28
31
  @http_cache = {}
29
32
  end
30
33
 
@@ -35,10 +38,11 @@ module Slk
35
38
  end
36
39
 
37
40
  def post(workspace, method, params = {})
38
- execute_request(method) do |uri, http|
41
+ body = params.empty? ? nil : JSON.generate(params)
42
+ execute_request(method, body: body) do |uri, http|
39
43
  request = Net::HTTP::Post.new(uri)
40
44
  workspace.headers.each { |k, v| request[k] = v }
41
- request.body = JSON.generate(params) unless params.empty?
45
+ request.body = body
42
46
  http.request(request)
43
47
  end
44
48
  end
@@ -53,10 +57,11 @@ module Slk
53
57
 
54
58
  # Form-encoded POST (some Slack endpoints require this)
55
59
  def post_form(workspace, method, params = {})
56
- execute_request(method) do |uri, http|
60
+ body = params.empty? ? nil : URI.encode_www_form(params)
61
+ execute_request(method, body: body) do |uri, http|
57
62
  request = Net::HTTP::Post.new(uri)
58
63
  apply_auth_headers(request, workspace)
59
- request.set_form_data(params)
64
+ request.set_form_data(params) unless params.empty?
60
65
  http.request(request)
61
66
  end
62
67
  end
@@ -66,19 +71,33 @@ module Slk
66
71
  def safe_close(http)
67
72
  http.finish if http.started?
68
73
  rescue IOError
69
- # Connection already closed
74
+ # Connection already closed - this is expected, not an error
70
75
  end
71
76
 
72
- def execute_request(method, query_params = nil)
77
+ def execute_request(method, query_params = nil, body: nil, &)
73
78
  log_request(method)
79
+ log_request_body(method, body)
80
+ uri = build_uri(method, query_params)
81
+ response, elapsed_ms = timed_request(uri, &)
82
+ log_response(method, response, elapsed_ms)
83
+ log_response_body(method, response.body)
84
+ handle_response(response, method)
85
+ rescue *NETWORK_ERRORS => e
86
+ raise ApiError, "Network error: #{e.message}"
87
+ end
88
+
89
+ def build_uri(method, query_params)
74
90
  uri = URI("#{BASE_URL}/#{method}")
75
91
  uri.query = URI.encode_www_form(query_params) if query_params&.any?
92
+ uri
93
+ end
76
94
 
95
+ def timed_request(uri)
96
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
97
  http = get_http(uri)
78
98
  response = yield(uri, http)
79
- handle_response(response, method)
80
- rescue *NETWORK_ERRORS => e
81
- raise ApiError, "Network error: #{e.message}"
99
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
100
+ [response, elapsed_ms]
82
101
  end
83
102
 
84
103
  def apply_auth_headers(request, workspace)
@@ -91,6 +110,29 @@ module Slk
91
110
  @on_request&.call(method, @call_count)
92
111
  end
93
112
 
113
+ def log_request_body(method, body)
114
+ @on_request_body&.call(method, body) if body
115
+ end
116
+
117
+ def log_response(method, response, elapsed_ms)
118
+ return unless @on_response
119
+
120
+ headers = {
121
+ 'elapsed_ms' => elapsed_ms,
122
+ 'X-Slack-Req-Id' => response['X-Slack-Req-Id'],
123
+ 'X-RateLimit-Limit' => response['X-RateLimit-Limit'],
124
+ 'X-RateLimit-Remaining' => response['X-RateLimit-Remaining'],
125
+ 'X-RateLimit-Reset' => response['X-RateLimit-Reset'],
126
+ 'Retry-After' => response['Retry-After']
127
+ }.compact
128
+
129
+ @on_response.call(method, response.code, headers)
130
+ end
131
+
132
+ def log_response_body(method, body)
133
+ @on_response_body&.call(method, body) if body
134
+ end
135
+
94
136
  # Get or create a persistent HTTP connection for the given URI
95
137
  def get_http(uri)
96
138
  key = "#{uri.host}:#{uri.port}"
@@ -126,11 +168,18 @@ module Slk
126
168
  case response
127
169
  when Net::HTTPSuccess then parse_success_response(response)
128
170
  when Net::HTTPUnauthorized then raise ApiError, 'Invalid token or session expired'
129
- when Net::HTTPTooManyRequests then raise ApiError, 'Rate limited - please wait and try again'
171
+ when Net::HTTPTooManyRequests then handle_rate_limit(response)
130
172
  else raise ApiError, "HTTP #{response.code}: #{response.message}"
131
173
  end
132
174
  end
133
175
 
176
+ 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'
181
+ end
182
+
134
183
  def parse_success_response(response)
135
184
  result = JSON.parse(response.body)
136
185
  raise ApiError, result['error'] || 'Unknown error' unless result['ok']
@@ -5,7 +5,7 @@ module Slk
5
5
  # Persistent cache for user names, channel names, and subteams
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class CacheStore
8
- attr_accessor :on_warning
8
+ attr_accessor :on_warning, :on_cache_access
9
9
 
10
10
  def initialize(paths: nil)
11
11
  @paths = paths || Support::XdgPaths.new
@@ -13,12 +13,24 @@ module Slk
13
13
  @channel_cache = {}
14
14
  @subteam_cache = {}
15
15
  @on_warning = nil
16
+ @on_cache_access = nil
16
17
  end
17
18
 
18
19
  # User cache methods
19
20
  def get_user(workspace_name, user_id)
20
21
  load_user_cache(workspace_name)
21
- @user_cache.dig(workspace_name, user_id)
22
+ result = @user_cache.dig(workspace_name, user_id)
23
+ log_cache_access('user', workspace_name, user_id, result)
24
+ result
25
+ end
26
+
27
+ def get_user_id_by_name(workspace_name, name)
28
+ load_user_cache(workspace_name)
29
+ cache = @user_cache[workspace_name] || {}
30
+ # Reverse lookup: find user_id where display_name matches
31
+ result = cache.find { |_id, display_name| display_name == name }&.first
32
+ log_cache_access('user_by_name', workspace_name, name, result)
33
+ result
22
34
  end
23
35
 
24
36
  def set_user(workspace_name, user_id, display_name, persist: false)
@@ -66,13 +78,17 @@ module Slk
66
78
  # Channel cache methods
67
79
  def get_channel_id(workspace_name, channel_name)
68
80
  load_channel_cache(workspace_name)
69
- @channel_cache.dig(workspace_name, channel_name)
81
+ result = @channel_cache.dig(workspace_name, channel_name)
82
+ log_cache_access('channel_id', workspace_name, channel_name, result)
83
+ result
70
84
  end
71
85
 
72
86
  def get_channel_name(workspace_name, channel_id)
73
87
  load_channel_cache(workspace_name)
74
88
  cache = @channel_cache[workspace_name] || {}
75
- cache.key(channel_id)
89
+ result = cache.key(channel_id)
90
+ log_cache_access('channel_name', workspace_name, channel_id, result)
91
+ result
76
92
  end
77
93
 
78
94
  def set_channel(workspace_name, channel_name, channel_id)
@@ -95,7 +111,9 @@ module Slk
95
111
  # Subteam cache methods
96
112
  def get_subteam(workspace_name, subteam_id)
97
113
  load_subteam_cache(workspace_name)
98
- @subteam_cache.dig(workspace_name, subteam_id)
114
+ result = @subteam_cache.dig(workspace_name, subteam_id)
115
+ log_cache_access('subteam', workspace_name, subteam_id, result)
116
+ result
99
117
  end
100
118
 
101
119
  def set_subteam(workspace_name, subteam_id, handle)
@@ -129,29 +147,37 @@ module Slk
129
147
  def load_user_cache(workspace_name)
130
148
  return if @user_cache.key?(workspace_name)
131
149
 
132
- file = user_cache_file(workspace_name)
133
- @user_cache[workspace_name] = if File.exist?(file)
134
- JSON.parse(File.read(file))
135
- else
136
- {}
137
- end
138
- rescue JSON::ParserError => e
139
- @on_warning&.call("User cache corrupted for #{workspace_name}: #{e.message}")
140
- @user_cache[workspace_name] = {}
150
+ @user_cache[workspace_name] = load_cache_file(user_cache_file(workspace_name), 'User', workspace_name)
141
151
  end
142
152
 
143
153
  def load_channel_cache(workspace_name)
144
154
  return if @channel_cache.key?(workspace_name)
145
155
 
146
- file = channel_cache_file(workspace_name)
147
- @channel_cache[workspace_name] = if File.exist?(file)
148
- JSON.parse(File.read(file))
149
- else
150
- {}
151
- end
156
+ @channel_cache[workspace_name] = load_cache_file(channel_cache_file(workspace_name), 'Channel', workspace_name)
157
+ end
158
+
159
+ def load_subteam_cache(workspace_name)
160
+ return if @subteam_cache.key?(workspace_name)
161
+
162
+ @subteam_cache[workspace_name] = load_cache_file(subteam_cache_file(workspace_name), 'Subteam', workspace_name)
163
+ end
164
+
165
+ # Load and parse a JSON cache file, returning empty hash if missing.
166
+ # Corrupted files are deleted and rebuilt on next access.
167
+ def load_cache_file(file, cache_type, workspace_name)
168
+ return {} unless File.exist?(file)
169
+
170
+ JSON.parse(File.read(file))
152
171
  rescue JSON::ParserError => e
153
- @on_warning&.call("Channel cache corrupted for #{workspace_name}: #{e.message}")
154
- @channel_cache[workspace_name] = {}
172
+ @on_warning&.call("#{cache_type} cache corrupted for #{workspace_name}: #{e.message}. Cache will be rebuilt.")
173
+ safely_delete_file(file)
174
+ {}
175
+ end
176
+
177
+ def safely_delete_file(file)
178
+ FileUtils.rm(file)
179
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS => e
180
+ @on_warning&.call("Could not remove corrupted cache file: #{e.message}")
155
181
  end
156
182
 
157
183
  def save_channel_cache(workspace_name)
@@ -170,20 +196,6 @@ module Slk
170
196
  @paths.cache_file("channels-#{workspace_name}.json")
171
197
  end
172
198
 
173
- def load_subteam_cache(workspace_name)
174
- return if @subteam_cache.key?(workspace_name)
175
-
176
- file = subteam_cache_file(workspace_name)
177
- @subteam_cache[workspace_name] = if File.exist?(file)
178
- JSON.parse(File.read(file))
179
- else
180
- {}
181
- end
182
- rescue JSON::ParserError => e
183
- @on_warning&.call("Subteam cache corrupted for #{workspace_name}: #{e.message}")
184
- @subteam_cache[workspace_name] = {}
185
- end
186
-
187
199
  def save_subteam_cache(workspace_name)
188
200
  return if @subteam_cache[workspace_name].nil? || @subteam_cache[workspace_name].empty?
189
201
 
@@ -195,6 +207,13 @@ module Slk
195
207
  def subteam_cache_file(workspace_name)
196
208
  @paths.cache_file("subteams-#{workspace_name}.json")
197
209
  end
210
+
211
+ def log_cache_access(type, workspace, key, result)
212
+ return unless @on_cache_access
213
+
214
+ hit = !result.nil?
215
+ @on_cache_access.call(type, workspace, key, hit, result)
216
+ end
198
217
  end
199
218
  # rubocop:enable Metrics/ClassLength
200
219
  end