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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +5 -5
  4. data/bin/slk +3 -3
  5. data/lib/{slack_cli → slk}/api/activity.rb +10 -11
  6. data/lib/{slack_cli → slk}/api/bots.rb +5 -4
  7. data/lib/slk/api/client.rb +51 -0
  8. data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
  9. data/lib/slk/api/dnd.rb +41 -0
  10. data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
  11. data/lib/{slack_cli → slk}/api/threads.rb +13 -12
  12. data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
  13. data/lib/slk/api/users.rb +105 -0
  14. data/lib/slk/cli.rb +157 -0
  15. data/lib/slk/commands/activity.rb +152 -0
  16. data/lib/{slack_cli → slk}/commands/base.rb +67 -41
  17. data/lib/slk/commands/cache.rb +141 -0
  18. data/lib/slk/commands/catchup.rb +411 -0
  19. data/lib/slk/commands/config.rb +114 -0
  20. data/lib/slk/commands/dnd.rb +172 -0
  21. data/lib/slk/commands/emoji.rb +352 -0
  22. data/lib/slk/commands/help.rb +97 -0
  23. data/lib/slk/commands/messages.rb +299 -0
  24. data/lib/slk/commands/presence.rb +109 -0
  25. data/lib/slk/commands/preset.rb +231 -0
  26. data/lib/slk/commands/status.rb +223 -0
  27. data/lib/slk/commands/thread.rb +72 -0
  28. data/lib/slk/commands/unread.rb +305 -0
  29. data/lib/slk/commands/workspaces.rb +168 -0
  30. data/lib/slk/formatters/activity_formatter.rb +148 -0
  31. data/lib/slk/formatters/attachment_formatter.rb +65 -0
  32. data/lib/slk/formatters/block_formatter.rb +57 -0
  33. data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
  34. data/lib/slk/formatters/emoji_replacer.rb +141 -0
  35. data/lib/slk/formatters/json_message_formatter.rb +95 -0
  36. data/lib/slk/formatters/mention_replacer.rb +158 -0
  37. data/lib/slk/formatters/message_formatter.rb +174 -0
  38. data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
  39. data/lib/slk/formatters/reaction_formatter.rb +87 -0
  40. data/lib/{slack_cli → slk}/models/channel.rb +12 -10
  41. data/lib/slk/models/duration.rb +94 -0
  42. data/lib/slk/models/message.rb +242 -0
  43. data/lib/slk/models/preset.rb +78 -0
  44. data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
  45. data/lib/{slack_cli → slk}/models/status.rb +6 -6
  46. data/lib/slk/models/user.rb +55 -0
  47. data/lib/slk/models/workspace.rb +54 -0
  48. data/lib/{slack_cli → slk}/runner.rb +22 -19
  49. data/lib/slk/services/activity_enricher.rb +124 -0
  50. data/lib/slk/services/api_client.rb +145 -0
  51. data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
  52. data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
  53. data/lib/slk/services/emoji_downloader.rb +103 -0
  54. data/lib/slk/services/emoji_searcher.rb +72 -0
  55. data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
  56. data/lib/slk/services/gemoji_sync.rb +97 -0
  57. data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
  58. data/lib/slk/services/reaction_enricher.rb +82 -0
  59. data/lib/slk/services/setup_wizard.rb +131 -0
  60. data/lib/slk/services/target_resolver.rb +108 -0
  61. data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
  62. data/lib/slk/services/unread_marker.rb +101 -0
  63. data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
  64. data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
  65. data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
  66. data/lib/slk/support/interactive_prompt.rb +29 -0
  67. data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
  68. data/lib/slk/support/text_wrapper.rb +57 -0
  69. data/lib/slk/support/user_resolver.rb +141 -0
  70. data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
  71. data/lib/slk/version.rb +5 -0
  72. data/lib/slk.rb +112 -0
  73. metadata +80 -59
  74. data/lib/slack_cli/api/client.rb +0 -49
  75. data/lib/slack_cli/api/dnd.rb +0 -40
  76. data/lib/slack_cli/api/users.rb +0 -101
  77. data/lib/slack_cli/cli.rb +0 -118
  78. data/lib/slack_cli/commands/activity.rb +0 -292
  79. data/lib/slack_cli/commands/cache.rb +0 -116
  80. data/lib/slack_cli/commands/catchup.rb +0 -484
  81. data/lib/slack_cli/commands/config.rb +0 -159
  82. data/lib/slack_cli/commands/dnd.rb +0 -143
  83. data/lib/slack_cli/commands/emoji.rb +0 -412
  84. data/lib/slack_cli/commands/help.rb +0 -76
  85. data/lib/slack_cli/commands/messages.rb +0 -317
  86. data/lib/slack_cli/commands/presence.rb +0 -107
  87. data/lib/slack_cli/commands/preset.rb +0 -239
  88. data/lib/slack_cli/commands/status.rb +0 -194
  89. data/lib/slack_cli/commands/thread.rb +0 -62
  90. data/lib/slack_cli/commands/unread.rb +0 -312
  91. data/lib/slack_cli/commands/workspaces.rb +0 -151
  92. data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
  93. data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
  94. data/lib/slack_cli/formatters/message_formatter.rb +0 -429
  95. data/lib/slack_cli/models/duration.rb +0 -85
  96. data/lib/slack_cli/models/message.rb +0 -217
  97. data/lib/slack_cli/models/preset.rb +0 -73
  98. data/lib/slack_cli/models/user.rb +0 -56
  99. data/lib/slack_cli/models/workspace.rb +0 -52
  100. data/lib/slack_cli/services/api_client.rb +0 -149
  101. data/lib/slack_cli/services/reaction_enricher.rb +0 -87
  102. data/lib/slack_cli/support/user_resolver.rb +0 -114
  103. data/lib/slack_cli/version.rb +0 -5
  104. 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 SlackCli
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
- File.delete(file) if File.exist?(file)
59
+ FileUtils.rm_f(file)
58
60
  else
59
61
  @user_cache.clear
60
- Dir.glob(@paths.cache_file("users-*.json")).each { |f| File.delete(f) }
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
- File.delete(file) if File.exist?(file)
88
+ FileUtils.rm_f(file)
87
89
  else
88
90
  @channel_cache.clear
89
- Dir.glob(@paths.cache_file("channels-*.json")).each { |f| File.delete(f) }
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
- JSON.parse(File.read(file))
133
- else
134
- {}
135
- end
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
- JSON.parse(File.read(file))
147
- else
148
- {}
149
- end
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
- JSON.parse(File.read(file))
177
- else
178
- {}
179
- end
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 SlackCli
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 # Lazy load to allow on_warning to be set first
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["primary_workspace"]
16
+ data['primary_workspace']
16
17
  end
17
18
 
18
19
  def primary_workspace=(name)
19
- data["primary_workspace"] = name
20
+ data['primary_workspace'] = name
20
21
  save_config
21
22
  end
22
23
 
23
24
  def ssh_key
24
- data["ssh_key"]
25
+ data['ssh_key']
25
26
  end
26
27
 
27
28
  def ssh_key=(path)
28
- data["ssh_key"] = path
29
+ data['ssh_key'] = path
29
30
  save_config
30
31
  end
31
32
 
32
33
  def emoji_dir
33
- data["emoji_dir"]
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("config.json")
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 "open3"
3
+ require 'open3'
4
4
 
5
- module SlackCli
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("which age > /dev/null 2>&1")
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, "age encryption tool not available" unless available?
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("age", "-R", public_key, "-o", output_file, stdin_data: content)
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, "age encryption tool not available" unless available?
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("age", "-d", "-i", ssh_key_path, encrypted_file)
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