slk 0.1.0 → 0.2.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 +22 -1
- data/README.md +5 -5
- data/bin/slk +3 -3
- data/lib/{slack_cli → slk}/api/activity.rb +10 -11
- data/lib/{slack_cli → slk}/api/bots.rb +5 -4
- data/lib/slk/api/client.rb +51 -0
- data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
- data/lib/slk/api/dnd.rb +41 -0
- data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
- data/lib/{slack_cli → slk}/api/threads.rb +13 -12
- data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
- data/lib/slk/api/users.rb +105 -0
- data/lib/slk/cli.rb +157 -0
- data/lib/slk/commands/activity.rb +152 -0
- data/lib/{slack_cli → slk}/commands/base.rb +67 -41
- data/lib/slk/commands/cache.rb +141 -0
- data/lib/slk/commands/catchup.rb +411 -0
- data/lib/slk/commands/config.rb +114 -0
- data/lib/slk/commands/dnd.rb +172 -0
- data/lib/slk/commands/emoji.rb +352 -0
- data/lib/slk/commands/help.rb +97 -0
- data/lib/slk/commands/messages.rb +299 -0
- data/lib/slk/commands/presence.rb +109 -0
- data/lib/slk/commands/preset.rb +231 -0
- data/lib/slk/commands/status.rb +223 -0
- data/lib/slk/commands/thread.rb +72 -0
- data/lib/slk/commands/unread.rb +305 -0
- data/lib/slk/commands/workspaces.rb +168 -0
- data/lib/slk/formatters/activity_formatter.rb +148 -0
- data/lib/slk/formatters/attachment_formatter.rb +65 -0
- data/lib/slk/formatters/block_formatter.rb +57 -0
- data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
- data/lib/slk/formatters/emoji_replacer.rb +141 -0
- data/lib/slk/formatters/json_message_formatter.rb +95 -0
- data/lib/slk/formatters/mention_replacer.rb +158 -0
- data/lib/slk/formatters/message_formatter.rb +174 -0
- data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
- data/lib/slk/formatters/reaction_formatter.rb +87 -0
- data/lib/{slack_cli → slk}/models/channel.rb +12 -10
- data/lib/slk/models/duration.rb +94 -0
- data/lib/slk/models/message.rb +242 -0
- data/lib/slk/models/preset.rb +78 -0
- data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
- data/lib/{slack_cli → slk}/models/status.rb +6 -6
- data/lib/slk/models/user.rb +55 -0
- data/lib/slk/models/workspace.rb +54 -0
- data/lib/{slack_cli → slk}/runner.rb +22 -19
- data/lib/slk/services/activity_enricher.rb +124 -0
- data/lib/slk/services/api_client.rb +145 -0
- data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
- data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
- data/lib/slk/services/emoji_downloader.rb +103 -0
- data/lib/slk/services/emoji_searcher.rb +72 -0
- data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
- data/lib/slk/services/gemoji_sync.rb +97 -0
- data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
- data/lib/slk/services/reaction_enricher.rb +82 -0
- data/lib/slk/services/setup_wizard.rb +131 -0
- data/lib/slk/services/target_resolver.rb +108 -0
- data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
- data/lib/slk/services/unread_marker.rb +101 -0
- data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
- data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
- data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
- data/lib/slk/support/interactive_prompt.rb +29 -0
- data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
- data/lib/slk/support/text_wrapper.rb +57 -0
- data/lib/slk/support/user_resolver.rb +141 -0
- data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
- data/lib/slk/version.rb +5 -0
- data/lib/slk.rb +112 -0
- metadata +80 -59
- data/lib/slack_cli/api/client.rb +0 -49
- data/lib/slack_cli/api/dnd.rb +0 -40
- data/lib/slack_cli/api/users.rb +0 -101
- data/lib/slack_cli/cli.rb +0 -118
- data/lib/slack_cli/commands/activity.rb +0 -292
- data/lib/slack_cli/commands/cache.rb +0 -116
- data/lib/slack_cli/commands/catchup.rb +0 -484
- data/lib/slack_cli/commands/config.rb +0 -159
- data/lib/slack_cli/commands/dnd.rb +0 -143
- data/lib/slack_cli/commands/emoji.rb +0 -412
- data/lib/slack_cli/commands/help.rb +0 -76
- data/lib/slack_cli/commands/messages.rb +0 -317
- data/lib/slack_cli/commands/presence.rb +0 -107
- data/lib/slack_cli/commands/preset.rb +0 -239
- data/lib/slack_cli/commands/status.rb +0 -194
- data/lib/slack_cli/commands/thread.rb +0 -62
- data/lib/slack_cli/commands/unread.rb +0 -312
- data/lib/slack_cli/commands/workspaces.rb +0 -151
- data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
- data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
- data/lib/slack_cli/formatters/message_formatter.rb +0 -429
- data/lib/slack_cli/models/duration.rb +0 -85
- data/lib/slack_cli/models/message.rb +0 -217
- data/lib/slack_cli/models/preset.rb +0 -73
- data/lib/slack_cli/models/user.rb +0 -56
- data/lib/slack_cli/models/workspace.rb +0 -52
- data/lib/slack_cli/services/api_client.rb +0 -149
- data/lib/slack_cli/services/reaction_enricher.rb +0 -87
- data/lib/slack_cli/support/user_resolver.rb +0 -114
- data/lib/slack_cli/version.rb +0 -5
- data/lib/slack_cli.rb +0 -91
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Enriches activity feed items with resolved user/channel names
|
|
6
|
+
class ActivityEnricher
|
|
7
|
+
def initialize(cache_store:, conversations_api:, on_debug: nil)
|
|
8
|
+
@cache = cache_store
|
|
9
|
+
@conversations_api = conversations_api
|
|
10
|
+
@on_debug = on_debug
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Enrich a list of activity items
|
|
14
|
+
def enrich_all(items, workspace)
|
|
15
|
+
items.map do |item|
|
|
16
|
+
enriched = item.dup
|
|
17
|
+
enrich_item(enriched, workspace)
|
|
18
|
+
enriched
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Enrich a single activity item based on its type
|
|
23
|
+
def enrich_item(item, workspace)
|
|
24
|
+
type = item.dig('item', 'type')
|
|
25
|
+
dispatch_enrich(type, item, workspace)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dispatch_enrich(type, item, workspace)
|
|
29
|
+
case type
|
|
30
|
+
when 'message_reaction' then enrich_reaction(item, workspace)
|
|
31
|
+
when 'at_user', 'at_user_group', 'at_channel', 'at_everyone' then enrich_mention(item, workspace)
|
|
32
|
+
when 'thread_v2' then enrich_thread(item, workspace)
|
|
33
|
+
when 'bot_dm_bundle' then enrich_bot_dm(item, workspace)
|
|
34
|
+
else @on_debug&.call("Unknown activity type: #{type.inspect}") if type
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Resolve user ID to name (cache-only)
|
|
39
|
+
def resolve_user(workspace, user_id)
|
|
40
|
+
@cache.get_user(workspace.name, user_id) || user_id
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Resolve channel ID to name (with API fallback)
|
|
44
|
+
def resolve_channel(workspace, channel_id, with_hash: true)
|
|
45
|
+
return 'DM' if channel_id.start_with?('D')
|
|
46
|
+
return 'Group DM' if channel_id.start_with?('G')
|
|
47
|
+
|
|
48
|
+
name = fetch_channel_name(workspace, channel_id)
|
|
49
|
+
return channel_id unless name
|
|
50
|
+
|
|
51
|
+
with_hash ? "##{name}" : name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def enrich_reaction(item, workspace)
|
|
57
|
+
reaction_data = item.dig('item', 'reaction')
|
|
58
|
+
message_data = item.dig('item', 'message')
|
|
59
|
+
return debug_missing('reaction', 'reaction or message') unless reaction_data && message_data
|
|
60
|
+
|
|
61
|
+
enrich_user(item, %w[item reaction], 'user', workspace)
|
|
62
|
+
enrich_channel(item, %w[item message], 'channel', workspace)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def enrich_mention(item, workspace)
|
|
66
|
+
message_data = item.dig('item', 'message')
|
|
67
|
+
return debug_missing('mention', 'message') unless message_data
|
|
68
|
+
|
|
69
|
+
user_id = message_data['author_user_id'] || message_data['user']
|
|
70
|
+
item['item']['message']['user_name'] = resolve_user(workspace, user_id) if user_id
|
|
71
|
+
enrich_channel(item, %w[item message], 'channel', workspace)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def enrich_thread(item, workspace)
|
|
75
|
+
thread_entry = item.dig('item', 'bundle_info', 'payload', 'thread_entry')
|
|
76
|
+
return debug_missing('thread', 'thread_entry') unless thread_entry
|
|
77
|
+
|
|
78
|
+
enrich_channel(item, %w[item bundle_info payload thread_entry], 'channel_id', workspace)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def enrich_bot_dm(item, workspace)
|
|
82
|
+
message_data = item.dig('item', 'bundle_info', 'payload', 'message')
|
|
83
|
+
return debug_missing('bot DM', 'message') unless message_data
|
|
84
|
+
|
|
85
|
+
enrich_channel(item, %w[item bundle_info payload message], 'channel', workspace)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def enrich_user(item, path, key, workspace)
|
|
89
|
+
data = item.dig(*path)
|
|
90
|
+
return unless data
|
|
91
|
+
|
|
92
|
+
user_id = data[key]
|
|
93
|
+
data['user_name'] = resolve_user(workspace, user_id) if user_id
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def enrich_channel(item, path, key, workspace)
|
|
97
|
+
data = item.dig(*path)
|
|
98
|
+
return unless data
|
|
99
|
+
|
|
100
|
+
channel_id = data[key]
|
|
101
|
+
data['channel_name'] = resolve_channel(workspace, channel_id, with_hash: false) if channel_id
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fetch_channel_name(workspace, channel_id)
|
|
105
|
+
cached = @cache.get_channel_name(workspace.name, channel_id)
|
|
106
|
+
return cached if cached
|
|
107
|
+
|
|
108
|
+
response = @conversations_api.info(channel: channel_id)
|
|
109
|
+
return nil unless response['ok'] && response['channel']
|
|
110
|
+
|
|
111
|
+
name = response['channel']['name']
|
|
112
|
+
@cache.set_channel(workspace.name, name, channel_id)
|
|
113
|
+
name
|
|
114
|
+
rescue ApiError => e
|
|
115
|
+
@on_debug&.call("Could not resolve channel #{channel_id}: #{e.message}")
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def debug_missing(item_type, missing_data)
|
|
120
|
+
@on_debug&.call("Could not enrich #{item_type} item - missing #{missing_data} data")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# HTTP client for Slack API with connection pooling
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
7
|
+
class ApiClient
|
|
8
|
+
BASE_URL = ENV.fetch('SLACK_API_BASE', 'https://slack.com/api')
|
|
9
|
+
|
|
10
|
+
# Network errors that should be wrapped in ApiError
|
|
11
|
+
NETWORK_ERRORS = [
|
|
12
|
+
SocketError,
|
|
13
|
+
Errno::ECONNREFUSED,
|
|
14
|
+
Errno::ECONNRESET,
|
|
15
|
+
Errno::ETIMEDOUT,
|
|
16
|
+
Errno::EHOSTUNREACH,
|
|
17
|
+
Net::OpenTimeout,
|
|
18
|
+
Net::ReadTimeout,
|
|
19
|
+
OpenSSL::SSL::SSLError
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :call_count
|
|
23
|
+
attr_accessor :on_request
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@call_count = 0
|
|
27
|
+
@on_request = nil
|
|
28
|
+
@http_cache = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Close all cached HTTP connections
|
|
32
|
+
def close
|
|
33
|
+
@http_cache.each_value { |http| safe_close(http) }
|
|
34
|
+
@http_cache.clear
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def post(workspace, method, params = {})
|
|
38
|
+
execute_request(method) do |uri, http|
|
|
39
|
+
request = Net::HTTP::Post.new(uri)
|
|
40
|
+
workspace.headers.each { |k, v| request[k] = v }
|
|
41
|
+
request.body = JSON.generate(params) unless params.empty?
|
|
42
|
+
http.request(request)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get(workspace, method, params = {})
|
|
47
|
+
execute_request(method, params) do |uri, http|
|
|
48
|
+
request = Net::HTTP::Get.new(uri)
|
|
49
|
+
apply_auth_headers(request, workspace)
|
|
50
|
+
http.request(request)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Form-encoded POST (some Slack endpoints require this)
|
|
55
|
+
def post_form(workspace, method, params = {})
|
|
56
|
+
execute_request(method) do |uri, http|
|
|
57
|
+
request = Net::HTTP::Post.new(uri)
|
|
58
|
+
apply_auth_headers(request, workspace)
|
|
59
|
+
request.set_form_data(params)
|
|
60
|
+
http.request(request)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def safe_close(http)
|
|
67
|
+
http.finish if http.started?
|
|
68
|
+
rescue IOError
|
|
69
|
+
# Connection already closed
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def execute_request(method, query_params = nil)
|
|
73
|
+
log_request(method)
|
|
74
|
+
uri = URI("#{BASE_URL}/#{method}")
|
|
75
|
+
uri.query = URI.encode_www_form(query_params) if query_params&.any?
|
|
76
|
+
|
|
77
|
+
http = get_http(uri)
|
|
78
|
+
response = yield(uri, http)
|
|
79
|
+
handle_response(response, method)
|
|
80
|
+
rescue *NETWORK_ERRORS => e
|
|
81
|
+
raise ApiError, "Network error: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def apply_auth_headers(request, workspace)
|
|
85
|
+
request['Authorization'] = workspace.headers['Authorization']
|
|
86
|
+
request['Cookie'] = workspace.headers['Cookie'] if workspace.headers['Cookie']
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def log_request(method)
|
|
90
|
+
@call_count += 1
|
|
91
|
+
@on_request&.call(method, @call_count)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get or create a persistent HTTP connection for the given URI
|
|
95
|
+
def get_http(uri)
|
|
96
|
+
key = "#{uri.host}:#{uri.port}"
|
|
97
|
+
cached = @http_cache[key]
|
|
98
|
+
|
|
99
|
+
# Return cached connection if it's still active
|
|
100
|
+
return cached if cached&.started?
|
|
101
|
+
|
|
102
|
+
# Create new connection
|
|
103
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
104
|
+
configure_ssl(http, uri)
|
|
105
|
+
http.start
|
|
106
|
+
|
|
107
|
+
@http_cache[key] = http
|
|
108
|
+
http
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def configure_ssl(http, uri)
|
|
112
|
+
http.use_ssl = uri.scheme == 'https'
|
|
113
|
+
http.open_timeout = 10
|
|
114
|
+
http.read_timeout = 30
|
|
115
|
+
http.keep_alive_timeout = 30
|
|
116
|
+
|
|
117
|
+
return unless http.use_ssl?
|
|
118
|
+
|
|
119
|
+
# Use system certificate store for SSL verification
|
|
120
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
121
|
+
http.cert_store = OpenSSL::X509::Store.new
|
|
122
|
+
http.cert_store.set_default_paths
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def handle_response(response, _method)
|
|
126
|
+
case response
|
|
127
|
+
when Net::HTTPSuccess then parse_success_response(response)
|
|
128
|
+
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'
|
|
130
|
+
else raise ApiError, "HTTP #{response.code}: #{response.message}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_success_response(response)
|
|
135
|
+
result = JSON.parse(response.body)
|
|
136
|
+
raise ApiError, result['error'] || 'Unknown error' unless result['ok']
|
|
137
|
+
|
|
138
|
+
result
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
raise ApiError, 'Invalid JSON response from Slack API'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
# rubocop:enable Metrics/ClassLength
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
4
|
module Services
|
|
5
|
+
# Persistent cache for user names, channel names, and subteams
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
5
7
|
class CacheStore
|
|
6
8
|
attr_accessor :on_warning
|
|
7
9
|
|
|
@@ -54,10 +56,10 @@ module SlackCli
|
|
|
54
56
|
if workspace_name
|
|
55
57
|
@user_cache.delete(workspace_name)
|
|
56
58
|
file = user_cache_file(workspace_name)
|
|
57
|
-
|
|
59
|
+
FileUtils.rm_f(file)
|
|
58
60
|
else
|
|
59
61
|
@user_cache.clear
|
|
60
|
-
Dir.glob(@paths.cache_file(
|
|
62
|
+
Dir.glob(@paths.cache_file('users-*.json')).each { |f| File.delete(f) }
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
|
|
@@ -83,10 +85,10 @@ module SlackCli
|
|
|
83
85
|
if workspace_name
|
|
84
86
|
@channel_cache.delete(workspace_name)
|
|
85
87
|
file = channel_cache_file(workspace_name)
|
|
86
|
-
|
|
88
|
+
FileUtils.rm_f(file)
|
|
87
89
|
else
|
|
88
90
|
@channel_cache.clear
|
|
89
|
-
Dir.glob(@paths.cache_file(
|
|
91
|
+
Dir.glob(@paths.cache_file('channels-*.json')).each { |f| File.delete(f) }
|
|
90
92
|
end
|
|
91
93
|
end
|
|
92
94
|
|
|
@@ -129,10 +131,10 @@ module SlackCli
|
|
|
129
131
|
|
|
130
132
|
file = user_cache_file(workspace_name)
|
|
131
133
|
@user_cache[workspace_name] = if File.exist?(file)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
JSON.parse(File.read(file))
|
|
135
|
+
else
|
|
136
|
+
{}
|
|
137
|
+
end
|
|
136
138
|
rescue JSON::ParserError => e
|
|
137
139
|
@on_warning&.call("User cache corrupted for #{workspace_name}: #{e.message}")
|
|
138
140
|
@user_cache[workspace_name] = {}
|
|
@@ -143,10 +145,10 @@ module SlackCli
|
|
|
143
145
|
|
|
144
146
|
file = channel_cache_file(workspace_name)
|
|
145
147
|
@channel_cache[workspace_name] = if File.exist?(file)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
JSON.parse(File.read(file))
|
|
149
|
+
else
|
|
150
|
+
{}
|
|
151
|
+
end
|
|
150
152
|
rescue JSON::ParserError => e
|
|
151
153
|
@on_warning&.call("Channel cache corrupted for #{workspace_name}: #{e.message}")
|
|
152
154
|
@channel_cache[workspace_name] = {}
|
|
@@ -173,10 +175,10 @@ module SlackCli
|
|
|
173
175
|
|
|
174
176
|
file = subteam_cache_file(workspace_name)
|
|
175
177
|
@subteam_cache[workspace_name] = if File.exist?(file)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
JSON.parse(File.read(file))
|
|
179
|
+
else
|
|
180
|
+
{}
|
|
181
|
+
end
|
|
180
182
|
rescue JSON::ParserError => e
|
|
181
183
|
@on_warning&.call("Subteam cache corrupted for #{workspace_name}: #{e.message}")
|
|
182
184
|
@subteam_cache[workspace_name] = {}
|
|
@@ -194,5 +196,6 @@ module SlackCli
|
|
|
194
196
|
@paths.cache_file("subteams-#{workspace_name}.json")
|
|
195
197
|
end
|
|
196
198
|
end
|
|
199
|
+
# rubocop:enable Metrics/ClassLength
|
|
197
200
|
end
|
|
198
201
|
end
|
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
4
|
module Services
|
|
5
|
+
# Manages CLI configuration stored in XDG config directory
|
|
5
6
|
class Configuration
|
|
6
7
|
attr_accessor :on_warning
|
|
7
8
|
|
|
8
9
|
def initialize(paths: Support::XdgPaths.new)
|
|
9
10
|
@paths = paths
|
|
10
11
|
@on_warning = nil
|
|
11
|
-
@data = nil
|
|
12
|
+
@data = nil # Lazy load to allow on_warning to be set first
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def primary_workspace
|
|
15
|
-
data[
|
|
16
|
+
data['primary_workspace']
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def primary_workspace=(name)
|
|
19
|
-
data[
|
|
20
|
+
data['primary_workspace'] = name
|
|
20
21
|
save_config
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def ssh_key
|
|
24
|
-
data[
|
|
25
|
+
data['ssh_key']
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def ssh_key=(path)
|
|
28
|
-
data[
|
|
29
|
+
data['ssh_key'] = path
|
|
29
30
|
save_config
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
def emoji_dir
|
|
33
|
-
data[
|
|
34
|
+
data['emoji_dir']
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def [](key)
|
|
@@ -53,7 +54,7 @@ module SlackCli
|
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
def config_file
|
|
56
|
-
@paths.config_file(
|
|
57
|
+
@paths.config_file('config.json')
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
def load_config
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Downloads workspace custom emoji to local cache
|
|
6
|
+
class EmojiDownloader
|
|
7
|
+
NETWORK_ERRORS = [
|
|
8
|
+
SocketError,
|
|
9
|
+
Errno::ECONNREFUSED,
|
|
10
|
+
Errno::ETIMEDOUT,
|
|
11
|
+
Net::OpenTimeout,
|
|
12
|
+
Net::ReadTimeout,
|
|
13
|
+
URI::InvalidURIError,
|
|
14
|
+
OpenSSL::SSL::SSLError
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(emoji_dir:, on_progress: nil, on_debug: nil)
|
|
18
|
+
@emoji_dir = emoji_dir
|
|
19
|
+
@on_progress = on_progress
|
|
20
|
+
@on_debug = on_debug
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Download custom emoji for a workspace
|
|
24
|
+
# @param workspace_name [String] Name of the workspace
|
|
25
|
+
# @param emoji_map [Hash] Map of emoji name to URL from API
|
|
26
|
+
# @return [Hash] Result with :downloaded, :skipped, :failed, :aliases
|
|
27
|
+
def download(workspace_name, emoji_map)
|
|
28
|
+
workspace_dir = File.join(@emoji_dir, workspace_name)
|
|
29
|
+
FileUtils.mkdir_p(workspace_dir)
|
|
30
|
+
|
|
31
|
+
to_download = emoji_map.reject { |_, url| url.start_with?('alias:') }
|
|
32
|
+
stats = initial_stats(emoji_map.size, to_download.size)
|
|
33
|
+
|
|
34
|
+
download_all(workspace_dir, to_download, stats)
|
|
35
|
+
stats
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def initial_stats(total_count, downloadable_count)
|
|
41
|
+
{ downloaded: 0, skipped: 0, failed: 0, aliases: total_count - downloadable_count, total: downloadable_count }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def download_all(workspace_dir, to_download, stats)
|
|
45
|
+
to_download.each_with_index do |(name, url), idx|
|
|
46
|
+
result = download_single(workspace_dir, name, url)
|
|
47
|
+
update_stats(stats, result)
|
|
48
|
+
report_progress(idx + 1, stats)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def update_stats(stats, result)
|
|
53
|
+
stats[result] += 1 if %i[downloaded skipped failed].include?(result)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def download_single(workspace_dir, name, url)
|
|
57
|
+
ext = File.extname(URI.parse(url).path)
|
|
58
|
+
ext = '.png' if ext.empty?
|
|
59
|
+
filepath = File.join(workspace_dir, "#{name}#{ext}")
|
|
60
|
+
|
|
61
|
+
# Skip if already exists
|
|
62
|
+
return :skipped if File.exist?(filepath)
|
|
63
|
+
|
|
64
|
+
# Download the emoji
|
|
65
|
+
download_file(url, filepath) ? :downloaded : :failed
|
|
66
|
+
rescue URI::InvalidURIError
|
|
67
|
+
:failed
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def download_file(url, filepath)
|
|
71
|
+
response = fetch_url(url)
|
|
72
|
+
return false unless response.is_a?(Net::HTTPSuccess)
|
|
73
|
+
|
|
74
|
+
File.binwrite(filepath, response.body)
|
|
75
|
+
true
|
|
76
|
+
rescue *NETWORK_ERRORS, SystemCallError => e
|
|
77
|
+
@on_debug&.call("Failed to download emoji: #{e.message}")
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fetch_url(url)
|
|
82
|
+
uri = URI.parse(url)
|
|
83
|
+
http = build_http_client(uri)
|
|
84
|
+
http.request(Net::HTTP::Get.new(uri))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_http_client(uri)
|
|
88
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
89
|
+
http.use_ssl = true
|
|
90
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
91
|
+
http.cert_store = OpenSSL::X509::Store.new
|
|
92
|
+
http.cert_store.set_default_paths
|
|
93
|
+
http.open_timeout = 10
|
|
94
|
+
http.read_timeout = 30
|
|
95
|
+
http
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def report_progress(current, stats)
|
|
99
|
+
@on_progress&.call(current, stats[:total], stats[:downloaded], stats[:skipped])
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Searches standard and workspace custom emoji
|
|
6
|
+
class EmojiSearcher
|
|
7
|
+
def initialize(cache_dir:, emoji_dir:, on_debug: nil)
|
|
8
|
+
@cache_dir = cache_dir
|
|
9
|
+
@emoji_dir = emoji_dir
|
|
10
|
+
@on_debug = on_debug
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Search emoji by pattern
|
|
14
|
+
# @param query [String] Search query
|
|
15
|
+
# @param workspaces [Array<Workspace>] Workspaces to search (nil for all)
|
|
16
|
+
# @return [Hash] Results grouped by source
|
|
17
|
+
def search(query, workspaces: [])
|
|
18
|
+
pattern = Regexp.new(Regexp.escape(query), Regexp::IGNORECASE)
|
|
19
|
+
results = []
|
|
20
|
+
|
|
21
|
+
# Search standard emoji
|
|
22
|
+
results.concat(search_standard(pattern))
|
|
23
|
+
|
|
24
|
+
# Search workspace custom emoji
|
|
25
|
+
workspaces.each do |workspace|
|
|
26
|
+
results.concat(search_workspace(workspace, pattern))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Group by source
|
|
30
|
+
results.group_by { |r| r[:source] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Search standard emoji only
|
|
34
|
+
# @param pattern [Regexp] Pattern to match
|
|
35
|
+
# @return [Array<Hash>] Matching emoji with :name, :char, :source
|
|
36
|
+
def search_standard(pattern)
|
|
37
|
+
gemoji = load_gemoji
|
|
38
|
+
return [] unless gemoji
|
|
39
|
+
|
|
40
|
+
gemoji.filter_map do |name, char|
|
|
41
|
+
{ name: name, char: char, source: 'standard' } if name.match?(pattern)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Search a workspace's custom emoji
|
|
46
|
+
# @param workspace [Workspace] Workspace to search
|
|
47
|
+
# @param pattern [Regexp] Pattern to match
|
|
48
|
+
# @return [Array<Hash>] Matching emoji with :name, :path, :source
|
|
49
|
+
def search_workspace(workspace, pattern)
|
|
50
|
+
workspace_dir = File.join(@emoji_dir, workspace.name)
|
|
51
|
+
return [] unless Dir.exist?(workspace_dir)
|
|
52
|
+
|
|
53
|
+
Dir.glob(File.join(workspace_dir, '*')).filter_map do |filepath|
|
|
54
|
+
name = File.basename(filepath, '.*')
|
|
55
|
+
{ name: name, path: filepath, source: workspace.name } if name.match?(pattern)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def load_gemoji
|
|
62
|
+
gemoji_path = File.join(@cache_dir, 'gemoji.json')
|
|
63
|
+
return nil unless File.exist?(gemoji_path)
|
|
64
|
+
|
|
65
|
+
JSON.parse(File.read(gemoji_path))
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
@on_debug&.call('Standard emoji cache corrupted, skipping')
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'open3'
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Slk
|
|
6
6
|
module Services
|
|
7
|
+
# Encrypts/decrypts tokens using age with SSH keys
|
|
7
8
|
class Encryption
|
|
8
9
|
def available?
|
|
9
|
-
system(
|
|
10
|
+
system('which age > /dev/null 2>&1')
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def encrypt(content, ssh_key_path, output_file)
|
|
13
|
-
raise EncryptionError,
|
|
13
|
+
def encrypt(content, ssh_key_path, output_file) # rubocop:disable Naming/PredicateMethod
|
|
14
|
+
raise EncryptionError, 'age encryption tool not available' unless available?
|
|
14
15
|
|
|
15
16
|
public_key = "#{ssh_key_path}.pub"
|
|
16
17
|
raise EncryptionError, "Public key not found: #{public_key}" unless File.exist?(public_key)
|
|
17
18
|
|
|
18
|
-
_output, error, status = Open3.capture3(
|
|
19
|
+
_output, error, status = Open3.capture3('age', '-R', public_key, '-o', output_file, stdin_data: content)
|
|
19
20
|
|
|
20
|
-
unless status.success?
|
|
21
|
-
raise EncryptionError, "Failed to encrypt: #{error.strip}"
|
|
22
|
-
end
|
|
21
|
+
raise EncryptionError, "Failed to encrypt: #{error.strip}" unless status.success?
|
|
23
22
|
|
|
24
23
|
true
|
|
25
24
|
end
|
|
@@ -33,14 +32,12 @@ module SlackCli
|
|
|
33
32
|
# File not existing is not an error - it just means no encrypted data yet
|
|
34
33
|
return nil unless File.exist?(encrypted_file)
|
|
35
34
|
|
|
36
|
-
raise EncryptionError,
|
|
35
|
+
raise EncryptionError, 'age encryption tool not available' unless available?
|
|
37
36
|
raise EncryptionError, "SSH key not found: #{ssh_key_path}" unless File.exist?(ssh_key_path)
|
|
38
37
|
|
|
39
|
-
output, error, status = Open3.capture3(
|
|
38
|
+
output, error, status = Open3.capture3('age', '-d', '-i', ssh_key_path, encrypted_file)
|
|
40
39
|
|
|
41
|
-
unless status.success?
|
|
42
|
-
raise EncryptionError, "Failed to decrypt #{encrypted_file}: #{error.strip}"
|
|
43
|
-
end
|
|
40
|
+
raise EncryptionError, "Failed to decrypt #{encrypted_file}: #{error.strip}" unless status.success?
|
|
44
41
|
|
|
45
42
|
output
|
|
46
43
|
rescue Errno::ENOENT => e
|