slk 0.4.2 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f36dfb6d59a99e348d4bd14d49eea6b3c77c9a7d077a59adc769529eee94a24
4
- data.tar.gz: f91ab312427b50df2b0938c8112d9057ab66b78f82eff31857a70b2e1b056a23
3
+ metadata.gz: 54edc7e40c9bcf891e2d2727c4cf50c4f43de73a70051465da12f3a97bc5282b
4
+ data.tar.gz: 40bbb5862b982c4e0d87d2a47c18b91a2f0070cac9fe7ad15a68e109114bf977
5
5
  SHA512:
6
- metadata.gz: 7e8e1ce4a53ead893a1c2275cac500aea5e3b78b46caeee31d8343e33e3598df27a4518fcb1d1322a4780b3b17b8472b2fdd319d740f0d918282a133a010f9d7
7
- data.tar.gz: 93d461ceacd6924ecf0b2758609f492a8d16e0919dc8288babbe0717416722e230dfbd991c3e119850c528463831636154fe3259197d8af5b4671b2ac3f53bd8
6
+ metadata.gz: 96abfcac2a271dccbce0a7b8068d23cf70d62ff68466e2a915fd67474aa9a3b6f648dce14ebaa87718c069993886a309c86490ab865a060586789bf8f8775752
7
+ data.tar.gz: ece446478a9dfda1dec3fa5dad6feff79c5f7be24e89f9835d61a0f3684e3d7839a94c673859ac5678a1f0f0c8675d31a9673493e744efcdd67433acda1f8fda
@@ -32,7 +32,8 @@ module Slk
32
32
  end
33
33
 
34
34
  def base_options
35
- { workspace: nil, all: false, verbose: false, quiet: false, json: false, markdown: false, width: default_width }
35
+ { workspace: nil, all: false, verbose: false, quiet: false, json: false, markdown: false,
36
+ width: default_width, fetch_attachments: false }
36
37
  end
37
38
 
38
39
  def formatting_options
@@ -79,6 +80,7 @@ module Slk
79
80
  when '--no-names' then @options[:no_names] = true
80
81
  when '--reaction-names' then @options[:reaction_names] = true
81
82
  when '--reaction-timestamps' then @options[:reaction_timestamps] = true
83
+ when '--fetch-attachments' then @options[:fetch_attachments] = true
82
84
  else handle_option(arg, args, remaining)
83
85
  end
84
86
  end
@@ -194,7 +196,8 @@ module Slk
194
196
  no_names: @options[:no_names],
195
197
  reaction_names: @options[:reaction_names],
196
198
  reaction_timestamps: @options[:reaction_timestamps],
197
- width: @options[:width]
199
+ width: @options[:width],
200
+ fetch_attachments: @options[:fetch_attachments]
198
201
  }
199
202
  end
200
203
  end
@@ -124,6 +124,7 @@ module Slk
124
124
  section.option('--no-names', 'Skip user name lookups (faster)')
125
125
  section.option('--reaction-names', 'Show reactions with user names')
126
126
  section.option('--reaction-timestamps', 'Show when each person reacted')
127
+ section.option('--fetch-attachments', 'Download files/images to local cache (~/.cache/slk/files/)')
127
128
  section.option('--width N', 'Wrap text at N columns (default: 72 on TTY, no wrap otherwise)')
128
129
  section.option('--no-wrap', 'Disable text wrapping')
129
130
  end
@@ -258,15 +259,22 @@ module Slk
258
259
  end
259
260
 
260
261
  def display_messages(messages, workspace, channel_id)
261
- formatter = runner.message_formatter
262
- opts = format_options.merge(channel_id: channel_id)
262
+ opts = build_display_options(messages, workspace, channel_id)
263
263
 
264
264
  messages.each_with_index do |message, index|
265
- display_single_message(formatter, message, workspace, opts)
265
+ display_single_message(runner.message_formatter, message, workspace, opts)
266
266
  puts if index < messages.length - 1
267
267
 
268
268
  show_thread_replies(workspace, channel_id, message, opts) if should_show_thread?(message)
269
269
  end
270
+
271
+ print_file_summary(messages) unless opts[:fetch_attachments]
272
+ end
273
+
274
+ def build_display_options(messages, workspace, channel_id)
275
+ opts = format_options.merge(channel_id: channel_id)
276
+ opts[:file_paths] = fetch_attachment_files(messages, workspace) if opts[:fetch_attachments]
277
+ opts
270
278
  end
271
279
 
272
280
  def should_show_thread?(message)
@@ -281,12 +289,20 @@ module Slk
281
289
  def show_thread_replies(workspace, channel_id, parent_message, opts)
282
290
  api = runner.conversations_api(workspace.name)
283
291
  replies = fetch_all_thread_replies(api, channel_id, parent_message.ts)
292
+ reply_messages = replies[1..].map { |r| Models::Message.from_api(r, channel_id: channel_id) }
284
293
 
285
- replies[1..].each { |reply_data| display_thread_reply(reply_data, workspace, channel_id, opts) }
294
+ download_reply_files(reply_messages, workspace, opts)
295
+ reply_messages.each { |reply| display_thread_reply_message(reply, workspace, opts) }
286
296
  end
287
297
 
288
- def display_thread_reply(reply_data, workspace, channel_id, opts)
289
- reply = Models::Message.from_api(reply_data, channel_id: channel_id)
298
+ def download_reply_files(replies, workspace, opts)
299
+ return unless opts[:fetch_attachments]
300
+
301
+ new_paths = fetch_attachment_files(replies, workspace)
302
+ opts[:file_paths].merge!(new_paths)
303
+ end
304
+
305
+ def display_thread_reply_message(reply, workspace, opts)
290
306
  formatted = runner.message_formatter.format(reply, workspace: workspace, options: opts)
291
307
 
292
308
  lines = formatted.lines
@@ -331,6 +347,28 @@ module Slk
331
347
  end
332
348
  end
333
349
 
350
+ def print_file_summary(messages)
351
+ file_count = messages.sum { |m| m.files.size + downloadable_attachment_count(m) }
352
+ return if file_count.zero?
353
+
354
+ label = file_count == 1 ? '1 file' : "#{file_count} files"
355
+ puts
356
+ info("#{label} not downloaded. Use --fetch-attachments to download.")
357
+ end
358
+
359
+ def downloadable_attachment_count(message)
360
+ message.attachments.count { |a| a['image_url'] || a['thumb_url'] }
361
+ end
362
+
363
+ def fetch_attachment_files(messages, workspace)
364
+ paths = Support::XdgPaths.new
365
+ downloader = Services::FileDownloader.new(
366
+ cache_dir: paths.cache_dir,
367
+ on_debug: ->(msg) { debug(msg) }
368
+ )
369
+ downloader.download_message_files(messages, workspace)
370
+ end
371
+
334
372
  def find_workspace_emoji(workspace_name, emoji_name)
335
373
  return nil if emoji_name.empty?
336
374
 
@@ -10,6 +10,16 @@ module Slk
10
10
  result = validate_options
11
11
  return result if result
12
12
 
13
+ resolve_and_display_thread
14
+ rescue ApiError => e
15
+ error("Failed to fetch messages: #{e.message}")
16
+ 1
17
+ rescue ArgumentError => e
18
+ error(e.message)
19
+ 1
20
+ end
21
+
22
+ def resolve_and_display_thread
13
23
  target = positional_args.first
14
24
  return usage_error unless target
15
25
 
@@ -18,12 +28,6 @@ module Slk
18
28
 
19
29
  resolved = target_resolver.resolve(target, default_workspace: target_workspaces.first)
20
30
  fetch_and_display_messages(resolved)
21
- rescue ApiError => e
22
- error("Failed to fetch messages: #{e.message}")
23
- 1
24
- rescue ArgumentError => e
25
- error(e.message)
26
- 1
27
31
  end
28
32
 
29
33
  def fetch_and_display_messages(resolved)
@@ -75,6 +79,7 @@ module Slk
75
79
  s.option('--no-emoji', 'Show :emoji: codes instead of unicode')
76
80
  s.option('--no-reactions', 'Hide reactions')
77
81
  s.option('--no-names', 'Skip user name lookups (faster)')
82
+ s.option('--fetch-attachments', 'Download files/images to local cache (~/.cache/slk/files/)')
78
83
  s.option('--json', 'Output as JSON')
79
84
  s.option('-v, --verbose', 'Show debug information')
80
85
  end
@@ -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)
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Downloads Slack message files and attachment images to XDG cache dir.
6
+ # Authed files (url_private_download) require workspace headers.
7
+ # Public attachment images (image_url) are fetched without auth.
8
+ # rubocop:disable Metrics/ClassLength
9
+ class FileDownloader
10
+ NETWORK_ERRORS = [
11
+ SocketError,
12
+ Errno::ECONNREFUSED,
13
+ Errno::ETIMEDOUT,
14
+ Net::OpenTimeout,
15
+ Net::ReadTimeout,
16
+ URI::InvalidURIError,
17
+ OpenSSL::SSL::SSLError
18
+ ].freeze
19
+
20
+ IMAGE_TYPES = %w[png jpg jpeg gif bmp webp svg].freeze
21
+ MAX_REDIRECTS = 3
22
+
23
+ def initialize(cache_dir:, on_debug: nil)
24
+ @cache_dir = cache_dir
25
+ @on_debug = on_debug
26
+ end
27
+
28
+ # Download all files from a list of messages, returning a hash of
29
+ # file_id => local_path for files and attachment index => local_path for attachments.
30
+ def download_message_files(messages, workspace)
31
+ files_dir = ensure_workspace_dir(workspace.name)
32
+ file_paths = {}
33
+
34
+ messages.each do |message|
35
+ download_files(message.files, files_dir, workspace, file_paths)
36
+ download_attachment_images(message.attachments, message.ts, files_dir, file_paths)
37
+ end
38
+
39
+ file_paths
40
+ end
41
+
42
+ private
43
+
44
+ def ensure_workspace_dir(workspace_name)
45
+ dir = File.join(@cache_dir, 'files', workspace_name)
46
+ FileUtils.mkdir_p(dir)
47
+ dir
48
+ end
49
+
50
+ def download_files(files, dir, workspace, paths)
51
+ files.each do |file|
52
+ path = download_single_file(file, dir, workspace)
53
+ paths[file['id']] = path if path
54
+ end
55
+ end
56
+
57
+ def download_single_file(file, dir, workspace)
58
+ file_id = file['id']
59
+ url = file['url_private_download']
60
+ return unless file_id && url
61
+
62
+ name = file['name'] || 'file'
63
+ local_path = File.join(dir, "#{file_id}_#{sanitize_filename(name)}")
64
+
65
+ return local_path if cached?(local_path, name)
66
+
67
+ download_authed(url, local_path, workspace) ? local_path : nil
68
+ end
69
+
70
+ def download_attachment_images(attachments, message_ts, dir, paths)
71
+ attachments.each_with_index do |att, idx|
72
+ path = download_single_attachment(att, message_ts, idx, dir)
73
+ paths["att_#{message_ts}_#{idx}"] = path if path
74
+ end
75
+ end
76
+
77
+ def download_single_attachment(att, message_ts, idx, dir)
78
+ url = att['image_url'] || att['thumb_url']
79
+ return unless url && downloadable_image_url?(url)
80
+
81
+ local_path = attachment_path(dir, message_ts, idx, url)
82
+ return local_path if cached?(local_path, 'attachment image')
83
+
84
+ download_public(url, local_path) ? local_path : nil
85
+ rescue URI::InvalidURIError
86
+ nil
87
+ end
88
+
89
+ def attachment_path(dir, message_ts, idx, url)
90
+ ext = File.extname(URI.parse(url).path)
91
+ ext = '.jpg' if ext.empty?
92
+ File.join(dir, "att_#{message_ts}_#{idx}#{ext}")
93
+ end
94
+
95
+ def cached?(local_path, label)
96
+ return false unless File.exist?(local_path)
97
+
98
+ @on_debug&.call("Skipping #{label} (cached)")
99
+ true
100
+ end
101
+
102
+ def downloadable_image_url?(url)
103
+ ext = File.extname(URI.parse(url).path).delete('.').downcase
104
+ IMAGE_TYPES.include?(ext) || url.include?('/giphy') || url.include?('tenor.com')
105
+ rescue URI::InvalidURIError
106
+ false
107
+ end
108
+
109
+ def download_authed(url, filepath, workspace)
110
+ uri = URI.parse(url)
111
+ request = Net::HTTP::Get.new(uri)
112
+ apply_workspace_headers(request, workspace)
113
+ write_response(build_http_client(uri).request(request), filepath)
114
+ rescue *NETWORK_ERRORS, SystemCallError => e
115
+ @on_debug&.call("Failed to download file: #{e.message}")
116
+ false
117
+ end
118
+
119
+ def apply_workspace_headers(request, workspace)
120
+ request['Authorization'] = workspace.headers['Authorization']
121
+ request['Cookie'] = workspace.headers['Cookie'] if workspace.headers['Cookie']
122
+ end
123
+
124
+ def download_public(url, filepath)
125
+ response = fetch_with_redirect(url)
126
+ write_response(response, filepath)
127
+ rescue *NETWORK_ERRORS, SystemCallError => e
128
+ @on_debug&.call("Failed to download attachment image: #{e.message}")
129
+ false
130
+ end
131
+
132
+ def fetch_with_redirect(url)
133
+ uri = URI.parse(url)
134
+ MAX_REDIRECTS.times do
135
+ response = build_http_client(uri).request(Net::HTTP::Get.new(uri))
136
+ return response unless response.is_a?(Net::HTTPRedirection) && response['location']
137
+
138
+ uri = resolve_redirect(uri, response['location'])
139
+ return response unless uri.host
140
+ end
141
+ # Exhausted redirects — return last response as-is
142
+ build_http_client(uri).request(Net::HTTP::Get.new(uri))
143
+ end
144
+
145
+ def resolve_redirect(original_uri, location)
146
+ parsed = URI.parse(location)
147
+ return parsed if parsed.host
148
+
149
+ # Relative redirect — resolve against original URI
150
+ URI.parse("#{original_uri.scheme}://#{original_uri.host}:#{original_uri.port}#{location}")
151
+ end
152
+
153
+ def write_response(response, filepath) # rubocop:disable Naming/PredicateMethod
154
+ return false unless response.is_a?(Net::HTTPSuccess)
155
+
156
+ File.binwrite(filepath, response.body)
157
+ @on_debug&.call("Downloaded #{File.basename(filepath)} (#{response.body.bytesize} bytes)")
158
+ true
159
+ end
160
+
161
+ def build_http_client(uri)
162
+ http = Net::HTTP.new(uri.host, uri.port)
163
+ http.use_ssl = uri.scheme == 'https'
164
+ if http.use_ssl?
165
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
166
+ http.cert_store = OpenSSL::X509::Store.new
167
+ http.cert_store.set_default_paths
168
+ end
169
+ http.open_timeout = 10
170
+ http.read_timeout = 30
171
+ http
172
+ end
173
+
174
+ def sanitize_filename(name)
175
+ name.gsub(%r{[/\\:*?"<>|]}, '_')
176
+ end
177
+ end
178
+ # rubocop:enable Metrics/ClassLength
179
+ end
180
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.4.2'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -49,6 +49,7 @@ module Slk
49
49
  autoload :ReactionEnricher, 'slk/services/reaction_enricher'
50
50
  autoload :GemojiSync, 'slk/services/gemoji_sync'
51
51
  autoload :EmojiDownloader, 'slk/services/emoji_downloader'
52
+ autoload :FileDownloader, 'slk/services/file_downloader'
52
53
  autoload :EmojiSearcher, 'slk/services/emoji_searcher'
53
54
  autoload :ActivityEnricher, 'slk/services/activity_enricher'
54
55
  autoload :UnreadMarker, 'slk/services/unread_marker'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -88,6 +88,7 @@ files:
88
88
  - lib/slk/services/emoji_downloader.rb
89
89
  - lib/slk/services/emoji_searcher.rb
90
90
  - lib/slk/services/encryption.rb
91
+ - lib/slk/services/file_downloader.rb
91
92
  - lib/slk/services/gemoji_sync.rb
92
93
  - lib/slk/services/message_resolver.rb
93
94
  - lib/slk/services/preset_store.rb
@@ -132,7 +133,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
132
133
  - !ruby/object:Gem::Version
133
134
  version: '0'
134
135
  requirements: []
135
- rubygems_version: 4.0.3
136
+ rubygems_version: 4.0.10
136
137
  specification_version: 4
137
138
  summary: A command-line interface for Slack
138
139
  test_files: []