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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -1
- data/README.md +30 -12
- data/bin/ci +15 -0
- data/bin/coverage +225 -0
- data/bin/test +7 -0
- data/lib/slk/api/search.rb +31 -0
- data/lib/slk/cli.rb +50 -3
- data/lib/slk/commands/base.rb +1 -0
- data/lib/slk/commands/catchup.rb +3 -2
- data/lib/slk/commands/config.rb +48 -45
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/messages.rb +59 -11
- data/lib/slk/commands/search.rb +223 -0
- data/lib/slk/commands/ssh_key_manager.rb +129 -0
- data/lib/slk/formatters/attachment_formatter.rb +16 -2
- data/lib/slk/formatters/mention_replacer.rb +13 -31
- data/lib/slk/formatters/message_formatter.rb +8 -15
- data/lib/slk/formatters/search_formatter.rb +75 -0
- data/lib/slk/models/search_result.rb +115 -0
- data/lib/slk/runner.rb +12 -0
- data/lib/slk/services/api_client.rb +60 -11
- data/lib/slk/services/cache_store.rb +55 -36
- data/lib/slk/services/encryption.rb +114 -11
- data/lib/slk/services/setup_wizard.rb +3 -3
- data/lib/slk/services/target_resolver.rb +27 -4
- data/lib/slk/services/token_loader.rb +83 -0
- data/lib/slk/services/token_saver.rb +87 -0
- data/lib/slk/services/token_store.rb +35 -65
- data/lib/slk/services/user_lookup.rb +117 -0
- data/lib/slk/support/date_parser.rb +64 -0
- data/lib/slk/support/platform.rb +34 -0
- data/lib/slk/support/xdg_paths.rb +27 -9
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +8 -0
- metadata +14 -1
data/lib/slk/commands/config.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
display_paths
|
|
56
|
+
print_config_values
|
|
57
|
+
print_paths
|
|
67
58
|
0
|
|
68
59
|
end
|
|
69
60
|
|
|
70
|
-
def
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/slk/commands/help.rb
CHANGED
|
@@ -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
|
-
|
|
137
|
-
|
|
138
|
-
channel_id
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
176
|
-
response = api.history(channel: channel_id, limit: @options[:limit], oldest:
|
|
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)
|