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
@@ -1,217 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SlackCli
4
- module Models
5
- Message = Data.define(
6
- :ts,
7
- :user_id,
8
- :text,
9
- :reactions,
10
- :reply_count,
11
- :thread_ts,
12
- :files,
13
- :attachments,
14
- :blocks,
15
- :user_profile,
16
- :bot_profile,
17
- :username,
18
- :subtype,
19
- :channel_id
20
- ) do
21
- # Minimum text length before we extract content from Block Kit blocks.
22
- # Slack sometimes sends minimal text (like a link preview) with the full
23
- # content in blocks. 20 chars catches most of these cases without
24
- # unnecessarily processing blocks for normal messages.
25
- BLOCK_TEXT_THRESHOLD = 20
26
-
27
- def self.from_api(data, channel_id: nil)
28
- text = data["text"] || ""
29
- blocks = data["blocks"] || []
30
-
31
- # Extract text from Block Kit blocks if text is empty or minimal
32
- if text.length < BLOCK_TEXT_THRESHOLD
33
- blocks_text = extract_block_text(blocks)
34
- text = blocks_text unless blocks_text.empty?
35
- end
36
-
37
- new(
38
- ts: data["ts"],
39
- user_id: data["user"] || data["bot_id"] || data["username"],
40
- text: text,
41
- reactions: (data["reactions"] || []).map { |r| Reaction.from_api(r) },
42
- reply_count: data["reply_count"] || 0,
43
- thread_ts: data["thread_ts"],
44
- files: data["files"] || [],
45
- attachments: data["attachments"] || [],
46
- blocks: blocks,
47
- user_profile: data["user_profile"],
48
- bot_profile: data["bot_profile"],
49
- username: data["username"],
50
- subtype: data["subtype"],
51
- channel_id: channel_id
52
- )
53
- end
54
-
55
- def self.extract_block_text(blocks)
56
- return "" unless blocks.is_a?(Array)
57
-
58
- blocks.filter_map do |block|
59
- case block["type"]
60
- when "section"
61
- block.dig("text", "text")
62
- when "rich_text"
63
- extract_rich_text_content(block["elements"])
64
- end
65
- end.join("\n")
66
- end
67
-
68
- def self.extract_rich_text_content(elements)
69
- return "" unless elements.is_a?(Array)
70
-
71
- elements.filter_map do |element|
72
- next unless element["elements"].is_a?(Array)
73
-
74
- element["elements"].filter_map do |item|
75
- item["text"] if item["type"] == "text"
76
- end.join
77
- end.join
78
- end
79
-
80
- def initialize(
81
- ts:,
82
- user_id:,
83
- text: "",
84
- reactions: [],
85
- reply_count: 0,
86
- thread_ts: nil,
87
- files: [],
88
- attachments: [],
89
- blocks: [],
90
- user_profile: nil,
91
- bot_profile: nil,
92
- username: nil,
93
- subtype: nil,
94
- channel_id: nil
95
- )
96
- ts_str = ts.to_s.strip
97
- user_id_str = user_id.to_s.strip
98
-
99
- raise ArgumentError, "ts cannot be empty" if ts_str.empty?
100
- raise ArgumentError, "user_id cannot be empty" if user_id_str.empty?
101
-
102
- super(
103
- ts: ts_str.freeze,
104
- user_id: user_id_str.freeze,
105
- text: text.to_s.freeze,
106
- reactions: reactions.freeze,
107
- reply_count: reply_count.to_i,
108
- thread_ts: thread_ts&.freeze,
109
- files: deep_freeze(files),
110
- attachments: deep_freeze(attachments),
111
- blocks: deep_freeze(blocks),
112
- user_profile: deep_freeze(user_profile),
113
- bot_profile: deep_freeze(bot_profile),
114
- username: username&.freeze,
115
- subtype: subtype&.freeze,
116
- channel_id: channel_id&.freeze
117
- )
118
- end
119
-
120
- # Recursively freeze nested structures (arrays and hashes)
121
- def self.deep_freeze(obj)
122
- case obj
123
- when Hash
124
- obj.each_value { |v| deep_freeze(v) }
125
- obj.freeze
126
- when Array
127
- obj.each { |v| deep_freeze(v) }
128
- obj.freeze
129
- else
130
- obj.freeze if obj.respond_to?(:freeze)
131
- end
132
- obj
133
- end
134
-
135
- private_class_method :deep_freeze
136
-
137
- # Instance method delegate to class method for use in initialize
138
- def deep_freeze(obj)
139
- self.class.send(:deep_freeze, obj)
140
- end
141
-
142
- def timestamp
143
- Time.at(ts.to_f)
144
- end
145
-
146
- def has_thread?
147
- reply_count > 0
148
- end
149
-
150
- def is_reply?
151
- thread_ts && thread_ts != ts
152
- end
153
-
154
- def has_reactions?
155
- !reactions.empty?
156
- end
157
-
158
- def has_files?
159
- !files.empty?
160
- end
161
-
162
- def has_blocks?
163
- !blocks.empty?
164
- end
165
-
166
- def embedded_username
167
- # Try user_profile first (regular users)
168
- if user_profile
169
- display = user_profile["display_name"]
170
- real = user_profile["real_name"]
171
-
172
- return display unless display.to_s.empty?
173
- return real unless real.to_s.empty?
174
- end
175
-
176
- # Try bot_profile (bot messages)
177
- if bot_profile
178
- name = bot_profile["name"]
179
- return name unless name.to_s.empty?
180
- end
181
-
182
- # Fall back to username field (some bots/integrations)
183
- return username unless username.to_s.empty?
184
-
185
- nil
186
- end
187
-
188
- def bot?
189
- user_id.start_with?("B") || subtype == "bot_message"
190
- end
191
-
192
- def system_message?
193
- %w[channel_join channel_leave channel_topic channel_purpose].include?(subtype)
194
- end
195
-
196
- # Create a copy of this message with updated reactions
197
- def with_reactions(new_reactions)
198
- Message.new(
199
- ts: ts,
200
- user_id: user_id,
201
- text: text,
202
- reactions: new_reactions,
203
- reply_count: reply_count,
204
- thread_ts: thread_ts,
205
- files: files,
206
- attachments: attachments,
207
- blocks: blocks,
208
- user_profile: user_profile,
209
- bot_profile: bot_profile,
210
- username: username,
211
- subtype: subtype,
212
- channel_id: channel_id
213
- )
214
- end
215
- end
216
- end
217
- end
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SlackCli
4
- module Models
5
- Preset = Data.define(:name, :text, :emoji, :duration, :presence, :dnd) do
6
- def self.from_hash(name, data)
7
- new(
8
- name: name,
9
- text: data["text"] || "",
10
- emoji: data["emoji"] || "",
11
- duration: data["duration"] || "0",
12
- presence: data["presence"] || "",
13
- dnd: data["dnd"] || ""
14
- )
15
- end
16
-
17
- def initialize(name:, text: "", emoji: "", duration: "0", presence: "", dnd: "")
18
- name_str = name.to_s.strip
19
- raise ArgumentError, "preset name cannot be empty" if name_str.empty?
20
-
21
- duration_str = duration.to_s
22
- # Validate duration at construction time (will raise ArgumentError if invalid)
23
- Duration.parse(duration_str) unless duration_str.empty? || duration_str == "0"
24
-
25
- super(
26
- name: name_str.freeze,
27
- text: text.to_s.freeze,
28
- emoji: emoji.to_s.freeze,
29
- duration: duration_str.freeze,
30
- presence: presence.to_s.freeze,
31
- dnd: dnd.to_s.freeze
32
- )
33
- end
34
-
35
- def to_h
36
- {
37
- "text" => text,
38
- "emoji" => emoji,
39
- "duration" => duration,
40
- "presence" => presence,
41
- "dnd" => dnd
42
- }
43
- end
44
-
45
- def duration_value
46
- Duration.parse(duration)
47
- end
48
-
49
- def sets_presence?
50
- !presence.empty?
51
- end
52
-
53
- def sets_dnd?
54
- !dnd.empty?
55
- end
56
-
57
- def clears_status?
58
- text.empty? && emoji.empty?
59
- end
60
-
61
- def to_s
62
- parts = []
63
- parts << emoji unless emoji.empty?
64
- parts << "\"#{text}\"" unless text.empty?
65
- parts << "(#{duration})" unless duration == "0" || duration.empty?
66
- parts << "[#{presence}]" if sets_presence?
67
- parts << "{dnd: #{dnd}}" if sets_dnd?
68
-
69
- "#{name}: #{parts.join(" ")}"
70
- end
71
- end
72
- end
73
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SlackCli
4
- module Models
5
- User = Data.define(:id, :name, :real_name, :display_name, :is_bot) do
6
- def self.from_api(data)
7
- profile = data["profile"] || {}
8
-
9
- new(
10
- id: data["id"],
11
- name: data["name"],
12
- real_name: profile["real_name"] || data["real_name"],
13
- display_name: profile["display_name"] || profile["display_name_normalized"],
14
- is_bot: data["is_bot"] || false
15
- )
16
- end
17
-
18
- # Slack user IDs start with U or W (enterprise grid)
19
- USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
20
-
21
- def initialize(id:, name: nil, real_name: nil, display_name: nil, is_bot: false)
22
- id_str = id.to_s.strip
23
- raise ArgumentError, "user id cannot be empty" if id_str.empty?
24
-
25
- # Validate user ID format (starts with U or W followed by alphanumeric)
26
- unless id_str.match?(USER_ID_PATTERN)
27
- raise ArgumentError, "invalid user id format: #{id_str} (expected U or W prefix)"
28
- end
29
-
30
- super(
31
- id: id_str.freeze,
32
- name: name&.freeze,
33
- real_name: real_name&.freeze,
34
- display_name: display_name&.freeze,
35
- is_bot: is_bot
36
- )
37
- end
38
-
39
- def best_name
40
- return display_name unless display_name.to_s.empty?
41
- return real_name unless real_name.to_s.empty?
42
- return name unless name.to_s.empty?
43
-
44
- id
45
- end
46
-
47
- def mention
48
- "@#{best_name}"
49
- end
50
-
51
- def to_s
52
- best_name
53
- end
54
- end
55
- end
56
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SlackCli
4
- module Models
5
- Workspace = Data.define(:name, :token, :cookie) do
6
- # Valid token prefixes for Slack tokens
7
- VALID_TOKEN_PREFIXES = %w[xoxb- xoxc- xoxp-].freeze
8
-
9
- def initialize(name:, token:, cookie: nil)
10
- name_str = name.to_s.strip
11
- token_str = token.to_s
12
- cookie_str = cookie&.to_s
13
-
14
- # Validate name is not empty and doesn't contain path separators
15
- raise ArgumentError, "workspace name cannot be empty" if name_str.empty?
16
- raise ArgumentError, "workspace name contains invalid characters" if name_str.match?(%r{[/\\]})
17
-
18
- # Validate token format
19
- unless VALID_TOKEN_PREFIXES.any? { |prefix| token_str.start_with?(prefix) }
20
- raise ArgumentError, "invalid token format (must start with xoxb-, xoxc-, or xoxp-)"
21
- end
22
-
23
- # xoxc tokens require a cookie
24
- if token_str.start_with?("xoxc-") && (cookie_str.nil? || cookie_str.strip.empty?)
25
- raise ArgumentError, "xoxc tokens require a cookie"
26
- end
27
-
28
- # Validate cookie doesn't contain newlines (HTTP header injection prevention)
29
- if cookie_str && cookie_str.match?(/[\r\n]/)
30
- raise ArgumentError, "cookie cannot contain newlines"
31
- end
32
-
33
- super(name: name_str.freeze, token: token_str.freeze, cookie: cookie_str&.freeze)
34
- end
35
-
36
- def xoxc? = token.start_with?("xoxc-")
37
- def xoxb? = token.start_with?("xoxb-")
38
- def xoxp? = token.start_with?("xoxp-")
39
-
40
- def to_s = name
41
-
42
- def headers
43
- h = {
44
- "Authorization" => "Bearer #{token}",
45
- "Content-Type" => "application/json; charset=utf-8"
46
- }
47
- h["Cookie"] = "d=#{cookie}" if cookie
48
- h
49
- end
50
- end
51
- end
52
- end
@@ -1,149 +0,0 @@
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
@@ -1,87 +0,0 @@
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