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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../support/help_formatter'
3
+ require_relative 'ssh_key_manager'
4
4
 
5
5
  module Slk
6
6
  module Commands
@@ -21,94 +21,97 @@ module Slk
21
21
  in ['setup'] | [_] then run_setup
22
22
  in ['get', key] then get_value(key)
23
23
  in ['set', key, value] then set_value(key, value)
24
+ in ['unset', key] then unset_value(key)
24
25
  end
25
26
  end
26
27
 
27
28
  protected
28
29
 
29
30
  def help_text
30
- help = Support::HelpFormatter.new('slk config [action]')
31
- help.description('Manage configuration.')
32
- add_actions_section(help)
33
- add_keys_section(help)
34
- add_options_section(help)
35
- help.render
31
+ Support::HelpFormatter.new('slk config [action]').tap do |h|
32
+ h.description('Manage configuration.')
33
+ h.section('ACTIONS') { |s| add_actions(s) }
34
+ h.section('CONFIG KEYS') { |s| add_keys(s) }
35
+ h.section('OPTIONS') { |s| s.option('-q, --quiet', 'Suppress output') }
36
+ end.render
36
37
  end
37
38
 
38
- def add_actions_section(help)
39
- help.section('ACTIONS') do |s|
40
- s.action('show', 'Show current configuration')
41
- s.action('setup', 'Run setup wizard')
42
- s.action('get <key>', 'Get a config value')
43
- s.action('set <key> <val>', 'Set a config value')
44
- end
39
+ def add_actions(section)
40
+ section.action('show', 'Show current configuration')
41
+ section.action('setup', 'Run setup wizard')
42
+ section.action('get <key>', 'Get a config value')
43
+ section.action('set <key> <val>', 'Set a config value')
44
+ section.action('unset <key>', 'Remove a config value')
45
45
  end
46
46
 
47
- def add_keys_section(help)
48
- help.section('CONFIG KEYS') do |s|
49
- s.item('primary_workspace', 'Default workspace name')
50
- s.item('ssh_key', 'Path to SSH key for encryption')
51
- s.item('emoji_dir', 'Custom emoji directory')
52
- end
53
- end
54
-
55
- def add_options_section(help)
56
- help.section('OPTIONS') do |s|
57
- s.option('-q, --quiet', 'Suppress output')
58
- end
47
+ def add_keys(section)
48
+ section.item('primary_workspace', 'Default workspace name')
49
+ section.item('ssh_key', 'Path to SSH key for encryption')
50
+ section.item('emoji_dir', 'Custom emoji directory')
59
51
  end
60
52
 
61
53
  private
62
54
 
63
55
  def show_config
64
- display_config_values
65
- display_workspace_info
66
- display_paths
56
+ print_config_values
57
+ print_paths
67
58
  0
68
59
  end
69
60
 
70
- def display_config_values
61
+ def print_config_values
71
62
  puts 'Configuration:'
72
63
  puts " Primary workspace: #{config.primary_workspace || '(not set)'}"
73
64
  puts " SSH key: #{config.ssh_key || '(not set)'}"
74
65
  puts " Emoji dir: #{config.emoji_dir || '(default)'}"
75
- end
76
-
77
- def display_workspace_info
78
66
  puts
79
67
  puts "Workspaces: #{runner.workspace_names.join(', ')}"
80
68
  end
81
69
 
82
- def display_paths
83
- puts
70
+ def print_paths
84
71
  paths = Support::XdgPaths.new
72
+ puts
85
73
  puts "Config dir: #{paths.config_dir}"
86
74
  puts "Cache dir: #{paths.cache_dir}"
87
75
  end
88
76
 
89
77
  def run_setup
90
- wizard = Services::SetupWizard.new(
91
- runner: runner,
92
- config: config,
93
- token_store: token_store,
94
- output: output
95
- )
96
- wizard.run
78
+ Services::SetupWizard.new(runner: runner, config: config, token_store: token_store, output: output).run
97
79
  end
98
80
 
99
81
  def get_value(key)
100
- value = config[key]
101
- puts value || '(not set)'
102
-
82
+ puts config[key] || '(not set)'
103
83
  0
104
84
  end
105
85
 
106
86
  def set_value(key, value)
87
+ return handle_ssh_key_result(ssh_key_manager.set(value)) if key == 'ssh_key'
88
+
107
89
  config[key] = value
108
90
  success("Set #{key} = #{value}")
91
+ 0
92
+ end
109
93
 
94
+ def unset_value(key)
95
+ return handle_ssh_key_result(ssh_key_manager.unset) if key == 'ssh_key'
96
+
97
+ config[key] = nil
98
+ success("Unset #{key}")
110
99
  0
111
100
  end
101
+
102
+ def ssh_key_manager
103
+ @ssh_key_manager ||= SshKeyManager.new(config: config, token_store: token_store, output: output).tap do |mgr|
104
+ mgr.on_info = ->(msg) { success(msg) }
105
+ mgr.on_warning = ->(msg) { warn(msg) }
106
+ end
107
+ end
108
+
109
+ def handle_ssh_key_result(result)
110
+ return success(result[:message]) || 0 if result[:success]
111
+
112
+ error(result[:error])
113
+ 1
114
+ end
112
115
  end
113
116
  end
114
117
  end
@@ -43,6 +43,7 @@ module Slk
43
43
  #{output.cyan('presence')} Get or set your presence (away/active)
44
44
  #{output.cyan('dnd')} Manage Do Not Disturb
45
45
  #{output.cyan('messages')} Read channel or DM messages
46
+ #{output.cyan('search')} Search messages across channels
46
47
  #{output.cyan('unread')} View and clear unread messages
47
48
  #{output.cyan('preset')} Manage and apply status presets
48
49
  #{output.cyan('workspaces')} Manage Slack workspaces
@@ -10,6 +10,7 @@ module Slk
10
10
  class Messages < Base
11
11
  include Support::InlineImages
12
12
 
13
+ # rubocop:disable Metrics/MethodLength
13
14
  def execute
14
15
  result = validate_options
15
16
  return result if result
@@ -22,7 +23,11 @@ module Slk
22
23
  rescue ApiError => e
23
24
  error("Failed to fetch messages: #{e.message}")
24
25
  1
26
+ rescue ArgumentError => e
27
+ error(e.message)
28
+ 1
25
29
  end
30
+ # rubocop:enable Metrics/MethodLength
26
31
 
27
32
  def missing_target_error
28
33
  error('Usage: slk messages <channel|@user|url>')
@@ -53,19 +58,28 @@ module Slk
53
58
  limit: 500,
54
59
  limit_set: false,
55
60
  threads: false,
56
- workspace_emoji: false
61
+ workspace_emoji: false,
62
+ since: nil
57
63
  )
58
64
  end
59
65
 
60
66
  def handle_option(arg, args, remaining)
61
67
  case arg
62
68
  when '-n', '--limit' then handle_limit_option(args)
69
+ when '--since' then handle_since_option(args)
63
70
  when '--threads' then @options[:threads] = true
64
71
  when '--workspace-emoji' then @options[:workspace_emoji] = true
65
72
  else super
66
73
  end
67
74
  end
68
75
 
76
+ def handle_since_option(args)
77
+ value = args.shift
78
+ raise ArgumentError, '--since requires a duration (1d, 7d, 1w, 1m) or date (YYYY-MM-DD)' unless value
79
+
80
+ @options[:since] = value
81
+ end
82
+
69
83
  def handle_limit_option(args)
70
84
  @options[:limit] = args.shift.to_i
71
85
  @options[:limit_set] = true
@@ -99,6 +113,7 @@ module Slk
99
113
 
100
114
  def add_message_options(section)
101
115
  section.option('-n, --limit N', 'Number of messages (default: 500, or 50 for message URLs)')
116
+ section.option('--since DURATION', 'Messages since duration (1d, 7d, 1w, 1m) or date (YYYY-MM-DD)')
102
117
  section.option('--threads', 'Show thread replies inline')
103
118
  section.option('--workspace-emoji', 'Show workspace emoji as inline images (experimental)')
104
119
  end
@@ -132,14 +147,37 @@ module Slk
132
147
  end
133
148
 
134
149
  def output_json_messages(messages, workspace, channel_id)
135
- format_options = {
136
- no_names: @options[:no_names],
137
- reaction_timestamps: @options[:reaction_timestamps],
138
- channel_id: channel_id
139
- }
140
- output_json(messages.map do |m|
141
- runner.message_formatter.format_json(m, workspace: workspace, options: format_options)
142
- end)
150
+ format_options = json_format_options(channel_id)
151
+
152
+ json_messages = messages.map do |message|
153
+ format_message_as_json(message, workspace, channel_id, format_options)
154
+ end
155
+
156
+ output_json(json_messages)
157
+ end
158
+
159
+ def json_format_options(channel_id)
160
+ { no_names: @options[:no_names], reaction_timestamps: @options[:reaction_timestamps], channel_id: channel_id }
161
+ end
162
+
163
+ def format_message_as_json(message, workspace, channel_id, format_options)
164
+ result = runner.message_formatter.format_json(message, workspace: workspace, options: format_options)
165
+ if should_show_thread?(message)
166
+ result[:replies] =
167
+ fetch_thread_replies_json(workspace, channel_id, message, format_options)
168
+ end
169
+ result
170
+ end
171
+
172
+ def fetch_thread_replies_json(workspace, channel_id, parent_message, format_options)
173
+ api = runner.conversations_api(workspace.name)
174
+ replies = fetch_all_thread_replies(api, channel_id, parent_message.ts)
175
+
176
+ # Skip parent (first element), format each reply as JSON
177
+ replies[1..].map do |reply_data|
178
+ reply = Models::Message.from_api(reply_data, channel_id: channel_id)
179
+ runner.message_formatter.format_json(reply, workspace: workspace, options: format_options)
180
+ end
143
181
  end
144
182
 
145
183
  # Apply default limit based on target type (50 for message URLs, 500 otherwise)
@@ -172,11 +210,21 @@ module Slk
172
210
  end
173
211
 
174
212
  def fetch_channel_history(api, channel_id, oldest)
175
- oldest_adjusted = oldest ? adjust_timestamp(oldest, -0.000001) : nil
176
- response = api.history(channel: channel_id, limit: @options[:limit], oldest: oldest_adjusted)
213
+ oldest_ts = determine_oldest_timestamp(oldest)
214
+ response = api.history(channel: channel_id, limit: @options[:limit], oldest: oldest_ts)
177
215
  response['messages'] || []
178
216
  end
179
217
 
218
+ def determine_oldest_timestamp(oldest_from_url)
219
+ # URL-based oldest takes precedence
220
+ return adjust_timestamp(oldest_from_url, -0.000001) if oldest_from_url
221
+
222
+ # Otherwise use --since if provided
223
+ return Support::DateParser.to_slack_timestamp(@options[:since]) if @options[:since]
224
+
225
+ nil
226
+ end
227
+
180
228
  # Adjust a Slack timestamp by a small amount while preserving precision
181
229
  def adjust_timestamp(timestamp, delta)
182
230
  require 'bigdecimal'
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # Searches messages across channels and DMs
8
+ # Note: Requires user tokens (xoxc/xoxs), NOT bot tokens (xoxb)
9
+ # rubocop:disable Metrics/ClassLength
10
+ class Search < Base
11
+ def execute
12
+ result = validate_options
13
+ return result if result
14
+
15
+ query = positional_args.first
16
+ return missing_query_error unless query
17
+
18
+ search_and_display(query)
19
+ rescue ApiError => e
20
+ handle_api_error(e)
21
+ rescue ArgumentError => e
22
+ error(e.message)
23
+ 1
24
+ end
25
+
26
+ protected
27
+
28
+ def default_options
29
+ super.merge(
30
+ limit: 20,
31
+ page: 1,
32
+ in_channel: nil,
33
+ from_user: nil,
34
+ after_date: nil,
35
+ before_date: nil,
36
+ on_date: nil,
37
+ threads: false
38
+ )
39
+ end
40
+
41
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
42
+ def handle_option(arg, args, remaining)
43
+ case arg
44
+ when '-n', '--limit' then @options[:limit] = require_value(arg, args).to_i
45
+ when '--page' then @options[:page] = require_value(arg, args).to_i
46
+ when '--in' then @options[:in_channel] = require_value(arg, args)
47
+ when '--from' then @options[:from_user] = require_value(arg, args)
48
+ when '--after' then @options[:after_date] = require_value(arg, args)
49
+ when '--before' then @options[:before_date] = require_value(arg, args)
50
+ when '--on' then @options[:on_date] = require_value(arg, args)
51
+ when '--threads' then @options[:threads] = true
52
+ else super
53
+ end
54
+ end
55
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
56
+
57
+ def require_value(option, args)
58
+ value = args.shift
59
+ raise ArgumentError, "#{option} requires a value" unless value
60
+
61
+ value
62
+ end
63
+
64
+ def help_text
65
+ help = Support::HelpFormatter.new('slk search <query> [options]')
66
+ help.description('Search messages across channels and DMs.')
67
+ help.note('Requires user token (xoxc/xoxs), not bot tokens.')
68
+ add_filter_section(help)
69
+ add_options_section(help)
70
+ help.render
71
+ end
72
+
73
+ private
74
+
75
+ def add_filter_section(help)
76
+ help.section('FILTERS') do |s|
77
+ s.option('--in #channel', 'Search in specific channel')
78
+ s.option('--from @user', 'Messages from specific user')
79
+ s.option('--after YYYY-MM-DD', 'Messages after date')
80
+ s.option('--before YYYY-MM-DD', 'Messages before date')
81
+ s.option('--on YYYY-MM-DD', 'Messages on specific date')
82
+ end
83
+ end
84
+
85
+ def add_options_section(help)
86
+ help.section('OPTIONS') do |s|
87
+ s.option('-n, --limit N', 'Number of results (default: 20, max: 100)')
88
+ s.option('--page N', 'Page number for pagination')
89
+ s.option('--threads', 'Show thread replies inline')
90
+ s.option('--json', 'Output as JSON')
91
+ s.option('-w, --workspace', 'Specify workspace')
92
+ s.option('-v, --verbose', 'Show debug information')
93
+ s.option('-q, --quiet', 'Suppress output')
94
+ end
95
+ end
96
+
97
+ def missing_query_error
98
+ error('Usage: slk search <query> [options]')
99
+ error('Example: slk search "deployment error" --in #engineering')
100
+ 1
101
+ end
102
+
103
+ def handle_api_error(err)
104
+ if err.message.include?('not_allowed_token_type')
105
+ error('Search requires a user token (xoxc/xoxs), not a bot token.')
106
+ error('Re-run `slk config` to set up with a user token.')
107
+ else
108
+ error("Search failed: #{err.message}")
109
+ end
110
+ 1
111
+ end
112
+
113
+ # rubocop:disable Metrics/MethodLength
114
+ def search_and_display(query)
115
+ workspace = target_workspaces.first
116
+ full_query = build_query(query)
117
+
118
+ debug("Searching: #{full_query}")
119
+ response = runner.search_api(workspace.name).messages(
120
+ query: full_query,
121
+ count: @options[:limit],
122
+ page: @options[:page]
123
+ )
124
+
125
+ results = parse_results(response)
126
+ display_results(results, workspace, response)
127
+ 0
128
+ end
129
+ # rubocop:enable Metrics/MethodLength
130
+
131
+ def build_query(base_query)
132
+ parts = [base_query]
133
+ parts << "in:#{@options[:in_channel]}" if @options[:in_channel]
134
+ parts << "from:#{@options[:from_user]}" if @options[:from_user]
135
+ parts << "after:#{@options[:after_date]}" if @options[:after_date]
136
+ parts << "before:#{@options[:before_date]}" if @options[:before_date]
137
+ parts << "on:#{@options[:on_date]}" if @options[:on_date]
138
+ parts.join(' ')
139
+ end
140
+
141
+ def parse_results(response)
142
+ matches = response.dig('messages', 'matches') || []
143
+ matches.map { |m| Models::SearchResult.from_api(m) }
144
+ end
145
+
146
+ def display_results(results, workspace, response)
147
+ if @options[:json]
148
+ output_json_results(results, response)
149
+ else
150
+ display_text_results(results, workspace, response)
151
+ end
152
+ end
153
+
154
+ def output_json_results(results, response)
155
+ pagination = response.dig('messages', 'pagination') || {}
156
+ output_json({
157
+ results: results.map(&:to_h),
158
+ pagination: {
159
+ page: pagination['page'],
160
+ page_count: pagination['page_count'],
161
+ total_count: pagination['total_count']
162
+ }
163
+ })
164
+ end
165
+
166
+ def display_text_results(results, workspace, response)
167
+ show_pagination_info(response) if @options[:verbose]
168
+
169
+ if results.empty?
170
+ puts 'No results found.'
171
+ return
172
+ end
173
+
174
+ results.each_with_index do |result, index|
175
+ display_single_result(result, workspace)
176
+ puts if index < results.length - 1
177
+ end
178
+ end
179
+
180
+ def display_single_result(result, workspace)
181
+ runner.search_formatter.display_result(result, workspace, format_options)
182
+ show_thread_replies(result, workspace) if should_show_thread?(result)
183
+ end
184
+
185
+ def should_show_thread?(result)
186
+ @options[:threads] && result.thread_ts == result.ts
187
+ end
188
+
189
+ def show_thread_replies(result, workspace)
190
+ api = runner.conversations_api(workspace.name)
191
+ replies = fetch_thread_replies(api, result.channel_id, result.ts)
192
+
193
+ replies[1..].each { |reply| display_thread_reply(reply, workspace, result.channel_id) }
194
+ rescue ApiError => e
195
+ debug("Failed to fetch thread replies for #{result.ts}: #{e.message}")
196
+ end
197
+
198
+ def fetch_thread_replies(api, channel_id, thread_ts)
199
+ response = api.replies(channel: channel_id, timestamp: thread_ts, limit: 100)
200
+ response['messages'] || []
201
+ end
202
+
203
+ def display_thread_reply(reply_data, workspace, channel_id)
204
+ message = Models::Message.from_api(reply_data, channel_id: channel_id)
205
+ formatted = runner.message_formatter.format(message, workspace: workspace, options: format_options)
206
+
207
+ lines = formatted.lines
208
+ puts " └ #{lines.first}"
209
+ lines[1..].each { |line| puts " #{line}" }
210
+ end
211
+
212
+ def show_pagination_info(response)
213
+ pagination = response.dig('messages', 'pagination') || {}
214
+ total = pagination['total_count'] || 0
215
+ page = pagination['page'] || 1
216
+ page_count = pagination['page_count'] || 1
217
+
218
+ debug("Page #{page}/#{page_count} (#{total} total results)")
219
+ end
220
+ end
221
+ # rubocop:enable Metrics/ClassLength
222
+ end
223
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # Handles SSH key configuration and token migration.
6
+ # When the SSH key is changed, existing tokens are decrypted with the old key
7
+ # and re-encrypted with the new key. Supports prompting for public key location
8
+ # if not found at the default path (private_key.pub).
9
+ class SshKeyManager
10
+ # File system errors mapped to user-friendly messages.
11
+ # These occur during SSH key file operations (reading keys, migrating tokens).
12
+ FILE_ERRORS = {
13
+ Errno::ENOENT => 'File not found',
14
+ Errno::EACCES => 'Permission denied',
15
+ Errno::EPERM => 'Permission denied',
16
+ Errno::ENOSPC => 'Disk full',
17
+ Errno::EDQUOT => 'Disk quota exceeded',
18
+ Errno::EROFS => 'Read-only file system'
19
+ }.freeze
20
+
21
+ attr_accessor :on_info, :on_warning
22
+
23
+ def initialize(config:, token_store:, output:)
24
+ @config = config
25
+ @token_store = token_store
26
+ @output = output
27
+ end
28
+
29
+ def set(new_path)
30
+ with_error_handling { perform_set(new_path) }
31
+ end
32
+
33
+ def unset
34
+ with_error_handling { perform_unset }
35
+ end
36
+
37
+ private
38
+
39
+ def perform_set(new_path)
40
+ new_path = normalize_path(new_path)
41
+ return error('Please provide the private key path, not the public key (.pub)') if pub_key?(new_path)
42
+
43
+ migrate_tokens(@config.ssh_key, new_path)
44
+ @config['ssh_key'] = new_path
45
+ success_message(new_path)
46
+ end
47
+
48
+ def perform_unset
49
+ old_path = resolve_old_path
50
+ migrate_tokens(old_path, nil)
51
+ @config['ssh_key'] = nil
52
+ success('Cleared ssh_key')
53
+ end
54
+
55
+ def with_error_handling
56
+ yield
57
+ rescue EncryptionError => e
58
+ error(e.message)
59
+ rescue ArgumentError => e
60
+ error("Invalid path: #{e.message}")
61
+ rescue *FILE_ERRORS.keys => e
62
+ error("#{FILE_ERRORS[e.class]}: #{e.message}")
63
+ end
64
+
65
+ def normalize_path(path)
66
+ path == '' ? nil : File.expand_path(path)
67
+ end
68
+
69
+ def pub_key?(path)
70
+ path&.end_with?('.pub')
71
+ end
72
+
73
+ def resolve_old_path
74
+ old_path = @config.ssh_key
75
+ old_path = nil if old_path.to_s.empty?
76
+
77
+ return old_path if old_path || !encrypted_tokens_exist?
78
+
79
+ prompt_for_key_path
80
+ end
81
+
82
+ def prompt_for_key_path
83
+ @output.puts 'Encrypted tokens exist but no ssh_key is configured.'
84
+ @output.print 'Enter path to SSH key for decryption: '
85
+ path = $stdin.gets&.chomp
86
+
87
+ if path.nil? || path.empty?
88
+ raise EncryptionError, 'SSH key path required to decrypt existing tokens. Operation cancelled.'
89
+ end
90
+
91
+ File.expand_path(path)
92
+ end
93
+
94
+ def encrypted_tokens_exist?
95
+ paths = Support::XdgPaths.new
96
+ File.exist?(paths.config_file('tokens.age'))
97
+ end
98
+
99
+ def migrate_tokens(old_path, new_path)
100
+ @token_store.on_info = ->(msg) { @on_info&.call(msg) }
101
+ @token_store.on_warning = ->(msg) { @on_warning&.call(msg) }
102
+ @token_store.on_prompt_pub_key = method(:prompt_for_pub_key)
103
+ @token_store.migrate_encryption(old_path, new_path)
104
+ end
105
+
106
+ def prompt_for_pub_key(private_key_path)
107
+ @output.puts "Public key not found at #{private_key_path}.pub"
108
+ @output.print 'Enter path to public key (or press Enter to cancel): '
109
+ path = $stdin.gets&.chomp
110
+ return nil if path.nil? || path.empty?
111
+
112
+ File.expand_path(path)
113
+ end
114
+
115
+ def success_message(new_path)
116
+ message = new_path ? "Set ssh_key = #{new_path}" : 'Cleared ssh_key'
117
+ success(message)
118
+ end
119
+
120
+ def success(message)
121
+ { success: true, message: message }
122
+ end
123
+
124
+ def error(message)
125
+ { success: false, error: message }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -18,16 +18,30 @@ module Slk
18
18
 
19
19
  private
20
20
 
21
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
21
22
  def format_attachment(attachment, lines, options)
22
23
  att_text = attachment['text'] || attachment['fallback']
23
24
  image_url = attachment['image_url'] || attachment['thumb_url']
25
+ block_images = extract_block_images(attachment)
24
26
 
25
- return unless att_text || image_url
27
+ return unless att_text || image_url || block_images.any?
26
28
 
27
29
  lines << ''
28
30
  format_author(attachment, lines)
29
- format_text(att_text, lines, options) if att_text
31
+ format_text(att_text, lines, options) if att_text && block_images.empty?
30
32
  format_image(attachment, image_url, lines) if image_url
33
+ block_images.each { |img| lines << "> [Image: #{img}]" }
34
+ end
35
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
36
+
37
+ def extract_block_images(attachment)
38
+ return [] unless attachment['blocks']
39
+
40
+ attachment['blocks'].filter_map do |block|
41
+ next unless block['type'] == 'image'
42
+
43
+ block.dig('title', 'text') || 'Image'
44
+ end
31
45
  end
32
46
 
33
47
  def format_author(attachment, lines)