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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/bin/slk +7 -0
  6. data/lib/slack_cli/api/activity.rb +28 -0
  7. data/lib/slack_cli/api/bots.rb +32 -0
  8. data/lib/slack_cli/api/client.rb +49 -0
  9. data/lib/slack_cli/api/conversations.rb +52 -0
  10. data/lib/slack_cli/api/dnd.rb +40 -0
  11. data/lib/slack_cli/api/emoji.rb +21 -0
  12. data/lib/slack_cli/api/threads.rb +44 -0
  13. data/lib/slack_cli/api/usergroups.rb +25 -0
  14. data/lib/slack_cli/api/users.rb +101 -0
  15. data/lib/slack_cli/cli.rb +118 -0
  16. data/lib/slack_cli/commands/activity.rb +292 -0
  17. data/lib/slack_cli/commands/base.rb +175 -0
  18. data/lib/slack_cli/commands/cache.rb +116 -0
  19. data/lib/slack_cli/commands/catchup.rb +484 -0
  20. data/lib/slack_cli/commands/config.rb +159 -0
  21. data/lib/slack_cli/commands/dnd.rb +143 -0
  22. data/lib/slack_cli/commands/emoji.rb +412 -0
  23. data/lib/slack_cli/commands/help.rb +76 -0
  24. data/lib/slack_cli/commands/messages.rb +317 -0
  25. data/lib/slack_cli/commands/presence.rb +107 -0
  26. data/lib/slack_cli/commands/preset.rb +239 -0
  27. data/lib/slack_cli/commands/status.rb +194 -0
  28. data/lib/slack_cli/commands/thread.rb +62 -0
  29. data/lib/slack_cli/commands/unread.rb +312 -0
  30. data/lib/slack_cli/commands/workspaces.rb +151 -0
  31. data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
  32. data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
  33. data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
  34. data/lib/slack_cli/formatters/message_formatter.rb +429 -0
  35. data/lib/slack_cli/formatters/output.rb +89 -0
  36. data/lib/slack_cli/models/channel.rb +52 -0
  37. data/lib/slack_cli/models/duration.rb +85 -0
  38. data/lib/slack_cli/models/message.rb +217 -0
  39. data/lib/slack_cli/models/preset.rb +73 -0
  40. data/lib/slack_cli/models/reaction.rb +54 -0
  41. data/lib/slack_cli/models/status.rb +57 -0
  42. data/lib/slack_cli/models/user.rb +56 -0
  43. data/lib/slack_cli/models/workspace.rb +52 -0
  44. data/lib/slack_cli/runner.rb +123 -0
  45. data/lib/slack_cli/services/api_client.rb +149 -0
  46. data/lib/slack_cli/services/cache_store.rb +198 -0
  47. data/lib/slack_cli/services/configuration.rb +74 -0
  48. data/lib/slack_cli/services/encryption.rb +51 -0
  49. data/lib/slack_cli/services/preset_store.rb +112 -0
  50. data/lib/slack_cli/services/reaction_enricher.rb +87 -0
  51. data/lib/slack_cli/services/token_store.rb +117 -0
  52. data/lib/slack_cli/support/error_logger.rb +28 -0
  53. data/lib/slack_cli/support/help_formatter.rb +139 -0
  54. data/lib/slack_cli/support/inline_images.rb +62 -0
  55. data/lib/slack_cli/support/slack_url_parser.rb +78 -0
  56. data/lib/slack_cli/support/user_resolver.rb +114 -0
  57. data/lib/slack_cli/support/xdg_paths.rb +37 -0
  58. data/lib/slack_cli/version.rb +5 -0
  59. data/lib/slack_cli.rb +91 -0
  60. 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