slk 0.1.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 +7 -0
- data/CHANGELOG.md +46 -0
- data/LICENSE +21 -0
- data/README.md +190 -0
- data/bin/slk +7 -0
- data/lib/slack_cli/api/activity.rb +28 -0
- data/lib/slack_cli/api/bots.rb +32 -0
- data/lib/slack_cli/api/client.rb +49 -0
- data/lib/slack_cli/api/conversations.rb +52 -0
- data/lib/slack_cli/api/dnd.rb +40 -0
- data/lib/slack_cli/api/emoji.rb +21 -0
- data/lib/slack_cli/api/threads.rb +44 -0
- data/lib/slack_cli/api/usergroups.rb +25 -0
- data/lib/slack_cli/api/users.rb +101 -0
- data/lib/slack_cli/cli.rb +118 -0
- data/lib/slack_cli/commands/activity.rb +292 -0
- data/lib/slack_cli/commands/base.rb +175 -0
- data/lib/slack_cli/commands/cache.rb +116 -0
- data/lib/slack_cli/commands/catchup.rb +484 -0
- data/lib/slack_cli/commands/config.rb +159 -0
- data/lib/slack_cli/commands/dnd.rb +143 -0
- data/lib/slack_cli/commands/emoji.rb +412 -0
- data/lib/slack_cli/commands/help.rb +76 -0
- data/lib/slack_cli/commands/messages.rb +317 -0
- data/lib/slack_cli/commands/presence.rb +107 -0
- data/lib/slack_cli/commands/preset.rb +239 -0
- data/lib/slack_cli/commands/status.rb +194 -0
- data/lib/slack_cli/commands/thread.rb +62 -0
- data/lib/slack_cli/commands/unread.rb +312 -0
- data/lib/slack_cli/commands/workspaces.rb +151 -0
- data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
- data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
- data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
- data/lib/slack_cli/formatters/message_formatter.rb +429 -0
- data/lib/slack_cli/formatters/output.rb +89 -0
- data/lib/slack_cli/models/channel.rb +52 -0
- data/lib/slack_cli/models/duration.rb +85 -0
- data/lib/slack_cli/models/message.rb +217 -0
- data/lib/slack_cli/models/preset.rb +73 -0
- data/lib/slack_cli/models/reaction.rb +54 -0
- data/lib/slack_cli/models/status.rb +57 -0
- data/lib/slack_cli/models/user.rb +56 -0
- data/lib/slack_cli/models/workspace.rb +52 -0
- data/lib/slack_cli/runner.rb +123 -0
- data/lib/slack_cli/services/api_client.rb +149 -0
- data/lib/slack_cli/services/cache_store.rb +198 -0
- data/lib/slack_cli/services/configuration.rb +74 -0
- data/lib/slack_cli/services/encryption.rb +51 -0
- data/lib/slack_cli/services/preset_store.rb +112 -0
- data/lib/slack_cli/services/reaction_enricher.rb +87 -0
- data/lib/slack_cli/services/token_store.rb +117 -0
- data/lib/slack_cli/support/error_logger.rb +28 -0
- data/lib/slack_cli/support/help_formatter.rb +139 -0
- data/lib/slack_cli/support/inline_images.rb +62 -0
- data/lib/slack_cli/support/slack_url_parser.rb +78 -0
- data/lib/slack_cli/support/user_resolver.rb +114 -0
- data/lib/slack_cli/support/xdg_paths.rb +37 -0
- data/lib/slack_cli/version.rb +5 -0
- data/lib/slack_cli.rb +91 -0
- metadata +103 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Services
|
|
5
|
+
class ApiClient
|
|
6
|
+
BASE_URL = ENV.fetch("SLACK_API_BASE", "https://slack.com/api")
|
|
7
|
+
|
|
8
|
+
# Network errors that should be wrapped in ApiError
|
|
9
|
+
NETWORK_ERRORS = [
|
|
10
|
+
SocketError,
|
|
11
|
+
Errno::ECONNREFUSED,
|
|
12
|
+
Errno::ECONNRESET,
|
|
13
|
+
Errno::ETIMEDOUT,
|
|
14
|
+
Errno::EHOSTUNREACH,
|
|
15
|
+
Net::OpenTimeout,
|
|
16
|
+
Net::ReadTimeout,
|
|
17
|
+
OpenSSL::SSL::SSLError
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :call_count
|
|
21
|
+
attr_accessor :on_request
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@call_count = 0
|
|
25
|
+
@on_request = nil
|
|
26
|
+
@http_cache = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Close all cached HTTP connections
|
|
30
|
+
def close
|
|
31
|
+
@http_cache.each_value do |http|
|
|
32
|
+
http.finish if http.started?
|
|
33
|
+
rescue IOError
|
|
34
|
+
# Connection already closed
|
|
35
|
+
end
|
|
36
|
+
@http_cache.clear
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def post(workspace, method, params = {})
|
|
40
|
+
log_request(method)
|
|
41
|
+
uri = URI("#{BASE_URL}/#{method}")
|
|
42
|
+
|
|
43
|
+
http = get_http(uri)
|
|
44
|
+
|
|
45
|
+
request = Net::HTTP::Post.new(uri)
|
|
46
|
+
workspace.headers.each { |k, v| request[k] = v }
|
|
47
|
+
request.body = JSON.generate(params) unless params.empty?
|
|
48
|
+
|
|
49
|
+
response = http.request(request)
|
|
50
|
+
handle_response(response, method)
|
|
51
|
+
rescue *NETWORK_ERRORS => e
|
|
52
|
+
raise ApiError, "Network error: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get(workspace, method, params = {})
|
|
56
|
+
log_request(method)
|
|
57
|
+
uri = URI("#{BASE_URL}/#{method}")
|
|
58
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
59
|
+
|
|
60
|
+
http = get_http(uri)
|
|
61
|
+
|
|
62
|
+
request = Net::HTTP::Get.new(uri)
|
|
63
|
+
request["Authorization"] = workspace.headers["Authorization"]
|
|
64
|
+
request["Cookie"] = workspace.headers["Cookie"] if workspace.headers["Cookie"]
|
|
65
|
+
|
|
66
|
+
response = http.request(request)
|
|
67
|
+
handle_response(response, method)
|
|
68
|
+
rescue *NETWORK_ERRORS => e
|
|
69
|
+
raise ApiError, "Network error: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Form-encoded POST (some Slack endpoints require this)
|
|
73
|
+
def post_form(workspace, method, params = {})
|
|
74
|
+
log_request(method)
|
|
75
|
+
uri = URI("#{BASE_URL}/#{method}")
|
|
76
|
+
|
|
77
|
+
http = get_http(uri)
|
|
78
|
+
|
|
79
|
+
request = Net::HTTP::Post.new(uri)
|
|
80
|
+
request["Authorization"] = workspace.headers["Authorization"]
|
|
81
|
+
request["Cookie"] = workspace.headers["Cookie"] if workspace.headers["Cookie"]
|
|
82
|
+
request.set_form_data(params)
|
|
83
|
+
|
|
84
|
+
response = http.request(request)
|
|
85
|
+
handle_response(response, method)
|
|
86
|
+
rescue *NETWORK_ERRORS => e
|
|
87
|
+
raise ApiError, "Network error: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def log_request(method)
|
|
93
|
+
@call_count += 1
|
|
94
|
+
@on_request&.call(method, @call_count)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Get or create a persistent HTTP connection for the given URI
|
|
98
|
+
def get_http(uri)
|
|
99
|
+
key = "#{uri.host}:#{uri.port}"
|
|
100
|
+
cached = @http_cache[key]
|
|
101
|
+
|
|
102
|
+
# Return cached connection if it's still active
|
|
103
|
+
if cached && cached.started?
|
|
104
|
+
return cached
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Create new connection
|
|
108
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
109
|
+
configure_ssl(http, uri)
|
|
110
|
+
http.start
|
|
111
|
+
|
|
112
|
+
@http_cache[key] = http
|
|
113
|
+
http
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def configure_ssl(http, uri)
|
|
117
|
+
http.use_ssl = uri.scheme == "https"
|
|
118
|
+
http.open_timeout = 10
|
|
119
|
+
http.read_timeout = 30
|
|
120
|
+
http.keep_alive_timeout = 30
|
|
121
|
+
|
|
122
|
+
return unless http.use_ssl?
|
|
123
|
+
|
|
124
|
+
# Use system certificate store for SSL verification
|
|
125
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
126
|
+
http.cert_store = OpenSSL::X509::Store.new
|
|
127
|
+
http.cert_store.set_default_paths
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def handle_response(response, method)
|
|
131
|
+
case response
|
|
132
|
+
when Net::HTTPSuccess
|
|
133
|
+
result = JSON.parse(response.body)
|
|
134
|
+
raise ApiError, result["error"] || "Unknown error" unless result["ok"]
|
|
135
|
+
|
|
136
|
+
result
|
|
137
|
+
when Net::HTTPUnauthorized
|
|
138
|
+
raise ApiError, "Invalid token or session expired"
|
|
139
|
+
when Net::HTTPTooManyRequests
|
|
140
|
+
raise ApiError, "Rate limited - please wait and try again"
|
|
141
|
+
else
|
|
142
|
+
raise ApiError, "HTTP #{response.code}: #{response.message}"
|
|
143
|
+
end
|
|
144
|
+
rescue JSON::ParserError
|
|
145
|
+
raise ApiError, "Invalid JSON response from Slack API"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Services
|
|
5
|
+
class CacheStore
|
|
6
|
+
attr_accessor :on_warning
|
|
7
|
+
|
|
8
|
+
def initialize(paths: nil)
|
|
9
|
+
@paths = paths || Support::XdgPaths.new
|
|
10
|
+
@user_cache = {}
|
|
11
|
+
@channel_cache = {}
|
|
12
|
+
@subteam_cache = {}
|
|
13
|
+
@on_warning = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# User cache methods
|
|
17
|
+
def get_user(workspace_name, user_id)
|
|
18
|
+
load_user_cache(workspace_name)
|
|
19
|
+
@user_cache.dig(workspace_name, user_id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set_user(workspace_name, user_id, display_name, persist: false)
|
|
23
|
+
load_user_cache(workspace_name)
|
|
24
|
+
@user_cache[workspace_name] ||= {}
|
|
25
|
+
@user_cache[workspace_name][user_id] = display_name
|
|
26
|
+
save_user_cache(workspace_name) if persist
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def user_cached?(workspace_name, user_id)
|
|
30
|
+
load_user_cache(workspace_name)
|
|
31
|
+
@user_cache.dig(workspace_name, user_id) != nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def save_user_cache(workspace_name)
|
|
35
|
+
return if @user_cache[workspace_name].nil? || @user_cache[workspace_name].empty?
|
|
36
|
+
|
|
37
|
+
@paths.ensure_cache_dir
|
|
38
|
+
file = user_cache_file(workspace_name)
|
|
39
|
+
File.write(file, JSON.pretty_generate(@user_cache[workspace_name]))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def populate_user_cache(workspace_name, users)
|
|
43
|
+
@user_cache[workspace_name] = {}
|
|
44
|
+
|
|
45
|
+
users.each do |user|
|
|
46
|
+
@user_cache[workspace_name][user.id] = user.best_name
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
save_user_cache(workspace_name)
|
|
50
|
+
@user_cache[workspace_name].size
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clear_user_cache(workspace_name = nil)
|
|
54
|
+
if workspace_name
|
|
55
|
+
@user_cache.delete(workspace_name)
|
|
56
|
+
file = user_cache_file(workspace_name)
|
|
57
|
+
File.delete(file) if File.exist?(file)
|
|
58
|
+
else
|
|
59
|
+
@user_cache.clear
|
|
60
|
+
Dir.glob(@paths.cache_file("users-*.json")).each { |f| File.delete(f) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Channel cache methods
|
|
65
|
+
def get_channel_id(workspace_name, channel_name)
|
|
66
|
+
load_channel_cache(workspace_name)
|
|
67
|
+
@channel_cache.dig(workspace_name, channel_name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_channel_name(workspace_name, channel_id)
|
|
71
|
+
load_channel_cache(workspace_name)
|
|
72
|
+
cache = @channel_cache[workspace_name] || {}
|
|
73
|
+
cache.key(channel_id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set_channel(workspace_name, channel_name, channel_id)
|
|
77
|
+
@channel_cache[workspace_name] ||= {}
|
|
78
|
+
@channel_cache[workspace_name][channel_name] = channel_id
|
|
79
|
+
save_channel_cache(workspace_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def clear_channel_cache(workspace_name = nil)
|
|
83
|
+
if workspace_name
|
|
84
|
+
@channel_cache.delete(workspace_name)
|
|
85
|
+
file = channel_cache_file(workspace_name)
|
|
86
|
+
File.delete(file) if File.exist?(file)
|
|
87
|
+
else
|
|
88
|
+
@channel_cache.clear
|
|
89
|
+
Dir.glob(@paths.cache_file("channels-*.json")).each { |f| File.delete(f) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Subteam cache methods
|
|
94
|
+
def get_subteam(workspace_name, subteam_id)
|
|
95
|
+
load_subteam_cache(workspace_name)
|
|
96
|
+
@subteam_cache.dig(workspace_name, subteam_id)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def set_subteam(workspace_name, subteam_id, handle)
|
|
100
|
+
load_subteam_cache(workspace_name)
|
|
101
|
+
@subteam_cache[workspace_name] ||= {}
|
|
102
|
+
@subteam_cache[workspace_name][subteam_id] = handle
|
|
103
|
+
save_subteam_cache(workspace_name)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Cache status
|
|
107
|
+
def user_cache_size(workspace_name)
|
|
108
|
+
load_user_cache(workspace_name)
|
|
109
|
+
@user_cache[workspace_name]&.size || 0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def channel_cache_size(workspace_name)
|
|
113
|
+
load_channel_cache(workspace_name)
|
|
114
|
+
@channel_cache[workspace_name]&.size || 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def user_cache_file_exists?(workspace_name)
|
|
118
|
+
File.exist?(user_cache_file(workspace_name))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def channel_cache_file_exists?(workspace_name)
|
|
122
|
+
File.exist?(channel_cache_file(workspace_name))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def load_user_cache(workspace_name)
|
|
128
|
+
return if @user_cache.key?(workspace_name)
|
|
129
|
+
|
|
130
|
+
file = user_cache_file(workspace_name)
|
|
131
|
+
@user_cache[workspace_name] = if File.exist?(file)
|
|
132
|
+
JSON.parse(File.read(file))
|
|
133
|
+
else
|
|
134
|
+
{}
|
|
135
|
+
end
|
|
136
|
+
rescue JSON::ParserError => e
|
|
137
|
+
@on_warning&.call("User cache corrupted for #{workspace_name}: #{e.message}")
|
|
138
|
+
@user_cache[workspace_name] = {}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_channel_cache(workspace_name)
|
|
142
|
+
return if @channel_cache.key?(workspace_name)
|
|
143
|
+
|
|
144
|
+
file = channel_cache_file(workspace_name)
|
|
145
|
+
@channel_cache[workspace_name] = if File.exist?(file)
|
|
146
|
+
JSON.parse(File.read(file))
|
|
147
|
+
else
|
|
148
|
+
{}
|
|
149
|
+
end
|
|
150
|
+
rescue JSON::ParserError => e
|
|
151
|
+
@on_warning&.call("Channel cache corrupted for #{workspace_name}: #{e.message}")
|
|
152
|
+
@channel_cache[workspace_name] = {}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def save_channel_cache(workspace_name)
|
|
156
|
+
return if @channel_cache[workspace_name].nil? || @channel_cache[workspace_name].empty?
|
|
157
|
+
|
|
158
|
+
@paths.ensure_cache_dir
|
|
159
|
+
file = channel_cache_file(workspace_name)
|
|
160
|
+
File.write(file, JSON.pretty_generate(@channel_cache[workspace_name]))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def user_cache_file(workspace_name)
|
|
164
|
+
@paths.cache_file("users-#{workspace_name}.json")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def channel_cache_file(workspace_name)
|
|
168
|
+
@paths.cache_file("channels-#{workspace_name}.json")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def load_subteam_cache(workspace_name)
|
|
172
|
+
return if @subteam_cache.key?(workspace_name)
|
|
173
|
+
|
|
174
|
+
file = subteam_cache_file(workspace_name)
|
|
175
|
+
@subteam_cache[workspace_name] = if File.exist?(file)
|
|
176
|
+
JSON.parse(File.read(file))
|
|
177
|
+
else
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
rescue JSON::ParserError => e
|
|
181
|
+
@on_warning&.call("Subteam cache corrupted for #{workspace_name}: #{e.message}")
|
|
182
|
+
@subteam_cache[workspace_name] = {}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def save_subteam_cache(workspace_name)
|
|
186
|
+
return if @subteam_cache[workspace_name].nil? || @subteam_cache[workspace_name].empty?
|
|
187
|
+
|
|
188
|
+
@paths.ensure_cache_dir
|
|
189
|
+
file = subteam_cache_file(workspace_name)
|
|
190
|
+
File.write(file, JSON.pretty_generate(@subteam_cache[workspace_name]))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def subteam_cache_file(workspace_name)
|
|
194
|
+
@paths.cache_file("subteams-#{workspace_name}.json")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Services
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :on_warning
|
|
7
|
+
|
|
8
|
+
def initialize(paths: Support::XdgPaths.new)
|
|
9
|
+
@paths = paths
|
|
10
|
+
@on_warning = nil
|
|
11
|
+
@data = nil # Lazy load to allow on_warning to be set first
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def primary_workspace
|
|
15
|
+
data["primary_workspace"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def primary_workspace=(name)
|
|
19
|
+
data["primary_workspace"] = name
|
|
20
|
+
save_config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ssh_key
|
|
24
|
+
data["ssh_key"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ssh_key=(path)
|
|
28
|
+
data["ssh_key"] = path
|
|
29
|
+
save_config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def emoji_dir
|
|
33
|
+
data["emoji_dir"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def [](key)
|
|
37
|
+
data[key]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def []=(key, value)
|
|
41
|
+
data[key] = value
|
|
42
|
+
save_config
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
data.dup
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def data
|
|
52
|
+
@data ||= load_config
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def config_file
|
|
56
|
+
@paths.config_file("config.json")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load_config
|
|
60
|
+
return {} unless File.exist?(config_file)
|
|
61
|
+
|
|
62
|
+
JSON.parse(File.read(config_file))
|
|
63
|
+
rescue JSON::ParserError => e
|
|
64
|
+
@on_warning&.call("Config file #{config_file} is corrupted (#{e.message}). Using defaults.")
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def save_config
|
|
69
|
+
@paths.ensure_config_dir
|
|
70
|
+
File.write(config_file, JSON.pretty_generate(data))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module SlackCli
|
|
6
|
+
module Services
|
|
7
|
+
class Encryption
|
|
8
|
+
def available?
|
|
9
|
+
system("which age > /dev/null 2>&1")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def encrypt(content, ssh_key_path, output_file)
|
|
13
|
+
raise EncryptionError, "age encryption tool not available" unless available?
|
|
14
|
+
|
|
15
|
+
public_key = "#{ssh_key_path}.pub"
|
|
16
|
+
raise EncryptionError, "Public key not found: #{public_key}" unless File.exist?(public_key)
|
|
17
|
+
|
|
18
|
+
_output, error, status = Open3.capture3("age", "-R", public_key, "-o", output_file, stdin_data: content)
|
|
19
|
+
|
|
20
|
+
unless status.success?
|
|
21
|
+
raise EncryptionError, "Failed to encrypt: #{error.strip}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Decrypt an age-encrypted file using an SSH key
|
|
28
|
+
# @param encrypted_file [String] Path to the encrypted file
|
|
29
|
+
# @param ssh_key_path [String] Path to the SSH private key
|
|
30
|
+
# @return [String, nil] Decrypted content, or nil if file doesn't exist
|
|
31
|
+
# @raise [EncryptionError] If age tool not available, key not found, or decryption fails
|
|
32
|
+
def decrypt(encrypted_file, ssh_key_path)
|
|
33
|
+
# File not existing is not an error - it just means no encrypted data yet
|
|
34
|
+
return nil unless File.exist?(encrypted_file)
|
|
35
|
+
|
|
36
|
+
raise EncryptionError, "age encryption tool not available" unless available?
|
|
37
|
+
raise EncryptionError, "SSH key not found: #{ssh_key_path}" unless File.exist?(ssh_key_path)
|
|
38
|
+
|
|
39
|
+
output, error, status = Open3.capture3("age", "-d", "-i", ssh_key_path, encrypted_file)
|
|
40
|
+
|
|
41
|
+
unless status.success?
|
|
42
|
+
raise EncryptionError, "Failed to decrypt #{encrypted_file}: #{error.strip}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
output
|
|
46
|
+
rescue Errno::ENOENT => e
|
|
47
|
+
raise EncryptionError, "Decryption failed: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Services
|
|
5
|
+
class PresetStore
|
|
6
|
+
DEFAULT_PRESETS = {
|
|
7
|
+
"meeting" => {
|
|
8
|
+
"text" => "In a meeting",
|
|
9
|
+
"emoji" => ":calendar:",
|
|
10
|
+
"duration" => "1h",
|
|
11
|
+
"presence" => "",
|
|
12
|
+
"dnd" => ""
|
|
13
|
+
},
|
|
14
|
+
"lunch" => {
|
|
15
|
+
"text" => "Lunch",
|
|
16
|
+
"emoji" => ":knife_fork_plate:",
|
|
17
|
+
"duration" => "1h",
|
|
18
|
+
"presence" => "away",
|
|
19
|
+
"dnd" => ""
|
|
20
|
+
},
|
|
21
|
+
"focus" => {
|
|
22
|
+
"text" => "Focus time",
|
|
23
|
+
"emoji" => ":headphones:",
|
|
24
|
+
"duration" => "2h",
|
|
25
|
+
"presence" => "",
|
|
26
|
+
"dnd" => "2h"
|
|
27
|
+
},
|
|
28
|
+
"brb" => {
|
|
29
|
+
"text" => "Be right back",
|
|
30
|
+
"emoji" => ":brb:",
|
|
31
|
+
"duration" => "15m",
|
|
32
|
+
"presence" => "away",
|
|
33
|
+
"dnd" => ""
|
|
34
|
+
},
|
|
35
|
+
"clear" => {
|
|
36
|
+
"text" => "",
|
|
37
|
+
"emoji" => "",
|
|
38
|
+
"duration" => "0",
|
|
39
|
+
"presence" => "auto",
|
|
40
|
+
"dnd" => "off"
|
|
41
|
+
}
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
attr_accessor :on_warning
|
|
45
|
+
|
|
46
|
+
def initialize(paths: nil)
|
|
47
|
+
@paths = paths || Support::XdgPaths.new
|
|
48
|
+
@on_warning = nil
|
|
49
|
+
ensure_default_presets
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get(name)
|
|
53
|
+
data = load_presets[name]
|
|
54
|
+
return nil unless data
|
|
55
|
+
|
|
56
|
+
Models::Preset.from_hash(name, data)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def all
|
|
60
|
+
load_presets.map { |name, data| Models::Preset.from_hash(name, data) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def names
|
|
64
|
+
load_presets.keys
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def exists?(name)
|
|
68
|
+
load_presets.key?(name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add(preset)
|
|
72
|
+
presets = load_presets
|
|
73
|
+
presets[preset.name] = preset.to_h
|
|
74
|
+
save_presets(presets)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def remove(name)
|
|
78
|
+
presets = load_presets
|
|
79
|
+
removed = presets.delete(name)
|
|
80
|
+
save_presets(presets) if removed
|
|
81
|
+
!removed.nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def ensure_default_presets
|
|
87
|
+
return if File.exist?(presets_file)
|
|
88
|
+
|
|
89
|
+
@paths.ensure_config_dir
|
|
90
|
+
save_presets(DEFAULT_PRESETS)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def load_presets
|
|
94
|
+
return {} unless File.exist?(presets_file)
|
|
95
|
+
|
|
96
|
+
JSON.parse(File.read(presets_file))
|
|
97
|
+
rescue JSON::ParserError => e
|
|
98
|
+
@on_warning&.call("Presets file #{presets_file} is corrupted (#{e.message}). Using defaults.")
|
|
99
|
+
{}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def save_presets(presets)
|
|
103
|
+
@paths.ensure_config_dir
|
|
104
|
+
File.write(presets_file, JSON.pretty_generate(presets))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def presets_file
|
|
108
|
+
@paths.config_file("presets.json")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SlackCli
|
|
4
|
+
module Services
|
|
5
|
+
class ReactionEnricher
|
|
6
|
+
def initialize(activity_api:)
|
|
7
|
+
@activity_api = activity_api
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Enriches messages with reaction timestamps
|
|
11
|
+
# Returns new array of messages with timestamps added to reactions
|
|
12
|
+
def enrich_messages(messages, channel_id)
|
|
13
|
+
return messages if messages.empty?
|
|
14
|
+
|
|
15
|
+
# Fetch reaction activity
|
|
16
|
+
activity_map = fetch_reaction_activity(channel_id, messages.map(&:ts))
|
|
17
|
+
|
|
18
|
+
# Enhance messages with timestamps
|
|
19
|
+
messages.map do |msg|
|
|
20
|
+
enhanced_reactions = enhance_reactions(msg, activity_map)
|
|
21
|
+
msg.with_reactions(enhanced_reactions)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def fetch_reaction_activity(channel_id, message_timestamps)
|
|
28
|
+
# Fetch first page of recent reactions (max 50 per API limit)
|
|
29
|
+
# Note: This may not cover all historical reactions, but that's acceptable
|
|
30
|
+
# for performance reasons. Older reactions simply won't have timestamps.
|
|
31
|
+
response = @activity_api.feed(limit: 50, types: 'message_reaction')
|
|
32
|
+
return {} unless response['ok']
|
|
33
|
+
|
|
34
|
+
# Build map: "channel_id:message_ts:emoji:user" => timestamp
|
|
35
|
+
activity_map = {}
|
|
36
|
+
items = response['items'] || []
|
|
37
|
+
|
|
38
|
+
items.each do |item|
|
|
39
|
+
next unless item.dig('item', 'type') == 'message_reaction'
|
|
40
|
+
|
|
41
|
+
msg_data = item.dig('item', 'message')
|
|
42
|
+
reaction_data = item.dig('item', 'reaction')
|
|
43
|
+
next unless msg_data && reaction_data
|
|
44
|
+
|
|
45
|
+
# Only include reactions for messages we care about
|
|
46
|
+
msg_ts = msg_data['ts']
|
|
47
|
+
next unless message_timestamps.include?(msg_ts)
|
|
48
|
+
|
|
49
|
+
key = [
|
|
50
|
+
msg_data['channel'],
|
|
51
|
+
msg_ts,
|
|
52
|
+
reaction_data['name'],
|
|
53
|
+
reaction_data['user']
|
|
54
|
+
].join(':')
|
|
55
|
+
|
|
56
|
+
activity_map[key] = item['feed_ts']
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
activity_map
|
|
60
|
+
rescue SlackCli::ApiError
|
|
61
|
+
# If activity API fails, gracefully degrade - return empty map
|
|
62
|
+
# Messages will still be displayed, just without reaction timestamps
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def enhance_reactions(message, activity_map)
|
|
67
|
+
return message.reactions if message.reactions.empty?
|
|
68
|
+
|
|
69
|
+
message.reactions.map do |reaction|
|
|
70
|
+
timestamp_map = {}
|
|
71
|
+
|
|
72
|
+
reaction.users.each do |user_id|
|
|
73
|
+
key = [message.channel_id, message.ts, reaction.name, user_id].join(':')
|
|
74
|
+
timestamp_map[user_id] = activity_map[key] if activity_map[key]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only create a new reaction with timestamps if we found any
|
|
78
|
+
if timestamp_map.empty?
|
|
79
|
+
reaction
|
|
80
|
+
else
|
|
81
|
+
reaction.with_timestamps(timestamp_map)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|