slk 0.10.0 → 0.11.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.
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders expanded sent-message conversations for people and scripts.
6
+ # rubocop:disable Metrics/ClassLength
7
+ class SentFormatter
8
+ def initialize(runner:, options: {})
9
+ @runner = runner
10
+ @options = options
11
+ @names = {}
12
+ end
13
+
14
+ def display(conversations, start_date:, end_date:, json: false)
15
+ if json
16
+ @runner.output.puts(JSON.pretty_generate(payload(conversations, start_date, end_date)))
17
+ else
18
+ display_text(conversations)
19
+ end
20
+ end
21
+
22
+ def display_changes(changes, changed_since:, lookback_days:, json: false)
23
+ if json
24
+ @runner.output.puts(JSON.pretty_generate(changes_payload(changes, changed_since, lookback_days)))
25
+ else
26
+ display_changed_text(changes)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def payload(conversations, start_date, end_date)
33
+ { date: start_date == end_date ? start_date.iso8601 : nil,
34
+ range: start_date == end_date ? nil : { since: start_date.iso8601, through: end_date.iso8601 },
35
+ conversations: conversations.map { |conversation| json_conversation(conversation) } }
36
+ end
37
+
38
+ def changes_payload(changes, since, days)
39
+ { date: nil, range: { since: (Date.today - days + 1).iso8601, through: Date.today.iso8601 },
40
+ changed_since: { iso: since.iso8601(since.usec.zero? ? 0 : 6),
41
+ ts: Support::CheckInTime.timestamp(since) },
42
+ lookback_days: days, conversations: changes.map { |change| json_change(change) } }
43
+ end
44
+
45
+ def json_change(change)
46
+ conversation = change.conversation
47
+ json_conversation(conversation).merge(
48
+ new_count: change.new_count, new_from_others: change.new_from_others,
49
+ messages: conversation.messages.map do |message|
50
+ json_message(message, conversation).merge(new: change.new_timestamps.include?(message.ts))
51
+ end
52
+ )
53
+ end
54
+
55
+ def json_conversation(conversation)
56
+ { workspace: conversation.workspace.name, channel_id: conversation.channel_id,
57
+ channel_name: conversation.channel_name, channel_label: channel_label(conversation), type: conversation.type,
58
+ thread_ts: conversation.thread_ts, last_speaker_is_me: conversation.last_speaker_is_me,
59
+ dropped_messages: conversation.dropped_messages,
60
+ messages: conversation.messages.map { |message| json_message(message, conversation) } }
61
+ end
62
+
63
+ def json_message(message, conversation)
64
+ { ts: message.ts, user: message.user_id, user_name: user_name(message, conversation.workspace),
65
+ text: message.text, mine: mine?(message, conversation), thread_ts: message.thread_ts }
66
+ end
67
+
68
+ def user_name(message, workspace)
69
+ key = [workspace.name, message.user_id]
70
+ @names[key] ||= message.embedded_username || @runner.cache_store.get_user(workspace.name, message.user_id) ||
71
+ Services::UserLookup.new(cache_store: @runner.cache_store, workspace: workspace,
72
+ api_client: @runner.api_client).resolve_name_or_bot(message.user_id) ||
73
+ message.user_id
74
+ end
75
+
76
+ def display_text(conversations)
77
+ return @runner.output.puts 'No sent conversations found.' if conversations.empty?
78
+
79
+ display_items(conversations) { |conversation| display_conversation(conversation) }
80
+ end
81
+
82
+ def display_conversation(conversation)
83
+ display_header(conversation)
84
+ display_messages(conversation.messages, conversation)
85
+ end
86
+
87
+ def display_items(items)
88
+ items.each_with_index do |item, index|
89
+ display_divider if index.positive?
90
+ yield item
91
+ @runner.output.puts
92
+ end
93
+ end
94
+
95
+ def display_divider
96
+ width = (@options[:width] || 32).clamp(1, 32)
97
+ @runner.output.puts @runner.output.gray('─' * width)
98
+ @runner.output.puts
99
+ end
100
+
101
+ def display_changed_text(changes)
102
+ return @runner.output.puts 'No changed sent conversations found.' if changes.empty?
103
+
104
+ display_items(changes) { |change| display_changed_conversation(change) }
105
+ end
106
+
107
+ def display_changed_conversation(change)
108
+ conversation = change.conversation
109
+ display_header(conversation, summary: "#{change.new_count} new (#{change.new_from_others} from others)")
110
+ context, fresh = conversation.messages.partition { |message| !change.new_timestamps.include?(message.ts) }
111
+ display_messages(context, conversation, context: true)
112
+ display_messages(fresh, conversation, parent_timestamps: context.map(&:ts))
113
+ end
114
+
115
+ def display_messages(messages, conversation, context: false, parent_timestamps: [])
116
+ replies = replies_by_parent(messages)
117
+ messages.each do |message|
118
+ next if replies.key?(message.thread_ts) && message.reply?
119
+
120
+ display_message(message, conversation,
121
+ orphan: message.reply? && !parent_timestamps.include?(message.thread_ts), context: context)
122
+ replies.fetch(message.ts, []).each { |reply| display_message(reply, conversation, context: context) }
123
+ end
124
+ end
125
+
126
+ def replies_by_parent(messages)
127
+ timestamps = messages.to_h { |message| [message.ts, true] }
128
+ messages.select { |message| message.reply? && timestamps.key?(message.thread_ts) }
129
+ .group_by(&:thread_ts)
130
+ end
131
+
132
+ # Header combines the resolved destination and optional thread ID.
133
+ def display_header(conversation, summary: nil)
134
+ @runner.output.puts wrap_heading(heading_for(conversation, summary))
135
+ return unless conversation.dropped_messages.positive?
136
+
137
+ @runner.output.puts "(#{conversation.dropped_messages} older messages omitted by --max)"
138
+ end
139
+
140
+ def heading_for(conversation, summary)
141
+ heading = "[#{conversation.workspace.name}] #{channel_label(conversation)}"
142
+ heading += " (thread: #{conversation.thread_ts})" if conversation.type == 'thread'
143
+ heading += " — #{summary}" if summary
144
+ heading
145
+ end
146
+
147
+ def display_message(message, conversation, orphan: false, context: false)
148
+ display_orphan_reference(message, context) if orphan
149
+ prefix = message.reply? ? ' ↳ ' : ''
150
+ prefix = "· #{prefix}" if context
151
+ line = "#{prefix}#{formatted_message(message, conversation, prefix)}"
152
+ @runner.output.puts(context ? @runner.output.gray(line) : line)
153
+ end
154
+
155
+ def display_orphan_reference(message, context)
156
+ line = " (thread #{message.thread_ts})"
157
+ line = "· #{line}" if context
158
+ @runner.output.puts(context ? @runner.output.gray(line) : line)
159
+ end
160
+
161
+ def formatted_message(message, conversation, prefix)
162
+ indent = ' ' * Support::TextWrapper.visible_length(prefix)
163
+ options = @options.dup
164
+ options[:width] -= indent.length if options[:width]
165
+ formatted = @runner.message_formatter.format(message, workspace: conversation.workspace, options: options)
166
+ indent.empty? ? formatted : formatted.gsub("\n", "\n#{indent}")
167
+ end
168
+
169
+ def wrap_heading(heading)
170
+ width = @options[:width]
171
+ return heading unless width && width > 2
172
+
173
+ Support::TextWrapper.wrap(heading, width, width - 2).gsub("\n", "\n ")
174
+ end
175
+
176
+ def channel_label(conversation)
177
+ @runner.sent_channel_label.label(
178
+ workspace: conversation.workspace, type: conversation.channel_type,
179
+ name: conversation.channel_name, channel_id: conversation.channel_id,
180
+ self_user_id: conversation.self_user_id, self_username: conversation.self_username
181
+ )
182
+ end
183
+
184
+ def mine?(message, conversation)
185
+ # The search result provides the authenticated sender's user ID. It
186
+ # remains attached to each conversation even if the first message is
187
+ # an older parent from someone else.
188
+ message.user_id == conversation.self_user_id
189
+ end
190
+ end
191
+ # rubocop:enable Metrics/ClassLength
192
+ end
193
+ end
@@ -13,6 +13,7 @@ module Slk
13
13
  :channel_name,
14
14
  :channel_type,
15
15
  :thread_ts,
16
+ :reply_count,
16
17
  :permalink,
17
18
  :files
18
19
  ) do
@@ -32,6 +33,7 @@ module Slk
32
33
  channel_name: channel['name'],
33
34
  channel_type: determine_channel_type(channel),
34
35
  thread_ts: extract_thread_ts(match),
36
+ reply_count: match['reply_count'].to_i,
35
37
  permalink: match['permalink'],
36
38
  files: extract_files(match)
37
39
  }
data/lib/slk/runner.rb CHANGED
@@ -159,6 +159,10 @@ module Slk
159
159
  )
160
160
  end
161
161
 
162
+ def sent_channel_label
163
+ @sent_channel_label ||= Services::SentChannelLabel.new(runner: self)
164
+ end
165
+
162
166
  # Logging
163
167
  def log_error(error)
164
168
  Support::ErrorLogger.log(error)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Walks search.messages pages without silently dropping results at Slack's
6
+ # 100-result page boundary. A limit caps results per workspace; nil fetches all.
7
+ class SearchPages
8
+ def initialize(search_api)
9
+ @search_api = search_api
10
+ end
11
+
12
+ # The loop tracks both the API page and the requested result cap.
13
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
14
+ def fetch(query:, limit: nil, page: 1, sort_dir: 'desc')
15
+ results = []
16
+ first_pagination = nil
17
+ matches = []
18
+ current = page
19
+ count = limit ? [limit, 100].min : 100
20
+
21
+ loop do
22
+ # Keep count fixed: Slack computes page offsets from count. Changing
23
+ # 100 to 50 for the last page would repeat rows from the first page.
24
+ response = @search_api.messages(query: query, count: count, page: current, sort_dir: sort_dir)
25
+ messages = response.fetch('messages', {})
26
+ matches = messages.fetch('matches', [])
27
+ pagination = messages.fetch('pagination', {})
28
+ first_pagination ||= pagination
29
+ remaining = limit ? limit - results.length : matches.length
30
+ results.concat(matches.first(remaining).map { |match| Models::SearchResult.from_api(match) })
31
+ break if matches.empty?
32
+
33
+ if !pagination['page_count'] && matches.length >= count
34
+ raise ApiError, 'Search response omitted pagination; cannot guarantee complete results'
35
+ end
36
+ break if limit && results.length >= limit
37
+ break unless pagination['page_count'] && current < pagination['page_count'].to_i
38
+
39
+ current += 1
40
+ end
41
+
42
+ { results: results, pagination: first_pagination || {},
43
+ truncated: truncated?(first_pagination, page, current, results, matches, limit) }
44
+ end
45
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
46
+
47
+ private
48
+
49
+ # A short limit can cut off a page even when Slack omits total_count.
50
+ # The page offset and last batch are needed when total_count is missing.
51
+ # rubocop:disable Metrics/ParameterLists
52
+ def truncated?(pagination, page, current, results, matches, limit)
53
+ return false unless limit
54
+
55
+ offset = (page - 1) * [limit, 100].min
56
+ total = pagination['total_count']
57
+ return total.to_i > offset + results.length if total
58
+
59
+ matches.length > [limit - ((current - page) * [limit, 100].min), 0].max ||
60
+ pagination['page_count'].to_i > current
61
+ end
62
+ # rubocop:enable Metrics/ParameterLists
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Slk
6
+ module Services
7
+ # Stateless diff of watched sent conversations. Search discovers what to
8
+ # watch; exact-ts history/replies determine what actually changed.
9
+ # Orchestration spans workspace watch sets, thread diffs and output windows.
10
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
11
+ class SentChanges
12
+ Change = Data.define(:conversation, :new_timestamps, :new_count, :new_from_others)
13
+ Conversation = SentConversations::Conversation
14
+ OPTIONAL_SUBSCRIPTION_ERRORS = %i[missing_scope not_allowed_token_type unknown_method].freeze
15
+
16
+ # rubocop:disable Metrics/ParameterLists
17
+ def initialize(runner:, since:, context: 2, max: 200, before: 5, after_minutes: 30)
18
+ @runner = runner
19
+ @since = since
20
+ @since_ts = Support::CheckInTime.timestamp(since)
21
+ @context = context
22
+ @max = max
23
+ @before = before
24
+ @after_minutes = after_minutes
25
+ end
26
+ # rubocop:enable Metrics/ParameterLists
27
+
28
+ def collect(entries, workspaces:)
29
+ groups = entries.group_by { |workspace, hit| [workspace.name, hit.channel_id] }
30
+ changes = workspaces.flat_map { |workspace| collect_workspace(workspace, groups) }
31
+ changes.sort_by do |change|
32
+ conversation = change.conversation
33
+ # Put the most recently active conversation last. Trailing identity
34
+ # keys make ties deterministic across platforms (including Windows).
35
+ [BigDecimal(conversation.messages.last.ts), conversation.workspace.name.to_s,
36
+ conversation.channel_id.to_s, conversation.thread_ts.to_s]
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def collect_workspace(workspace, groups)
43
+ @workspace = workspace
44
+ @api = @runner.conversations_api(workspace.name)
45
+ @self_id = self_id(workspace, groups)
46
+ local = groups.select { |(name, _id), _hits| name == workspace.name }
47
+ changed_threads = local.flat_map { |(_name, _id), entries| thread_changes(entries.map(&:last)) }
48
+ changed_threads.concat(subscribed_changes(changed_threads, local))
49
+ claimed = changed_threads.flat_map { |change| change.conversation.messages.map(&:ts) }.to_h { |ts| [ts, true] }
50
+ other = local.flat_map do |(_name, _id), entries|
51
+ hits = entries.map(&:last)
52
+ hits.first.dm? ? dm_changes(hits, claimed) : channel_changes(hits, claimed)
53
+ end
54
+ changed_threads + other
55
+ end
56
+
57
+ def self_id(workspace, groups)
58
+ entries = groups.find { |(name, _id), rows| name == workspace.name && rows.any? }&.last
59
+ hit = entries&.first&.last
60
+ return hit.user_id if hit&.user_id.to_s.match?(/\A[UW][A-Z0-9]+\z/i)
61
+
62
+ cached = @runner.cache_store.get_meta(workspace.name, 'self_user_id')
63
+ return cached if cached
64
+
65
+ id = @runner.client_api(workspace.name).auth_test['user_id']
66
+ raise ApiError, "Cannot identify sender in #{workspace.name}" unless id
67
+
68
+ @runner.cache_store.set_meta(workspace.name, 'self_user_id', id)
69
+ id
70
+ end
71
+
72
+ def thread_changes(hits)
73
+ metadata = root_metadata(hits)
74
+ hits.map { |hit| [hit.thread_ts || hit.ts, hit] }.uniq(&:first).filter_map do |root, seed|
75
+ parent = metadata[root]
76
+ next if parent && !thread_active?(parent)
77
+
78
+ thread_change(seed, root, full_first: parent && parent['latest_reply'])
79
+ end
80
+ end
81
+
82
+ # Fetch the watched top-level parents in one paginated history scan per
83
+ # channel. Search often omits reply_count; history has latest_reply.
84
+ def root_metadata(hits)
85
+ roots = hits.select { |hit| top_level?(hit) }.map(&:ts).uniq
86
+ return {} if roots.empty?
87
+
88
+ wanted = roots.to_h { |root| [root, true] }
89
+ found = {}
90
+ cursor = nil
91
+ loop do
92
+ response = @api.history(channel: hits.first.channel_id,
93
+ oldest: roots.min_by { |root| BigDecimal(root) }, inclusive: true,
94
+ limit: 200, cursor: cursor)
95
+ response.fetch('messages', []).each do |message|
96
+ found[message['ts']] = message if wanted[message['ts']]
97
+ end
98
+ break if found.size == wanted.size || !response['has_more']
99
+
100
+ cursor = next_cursor!(response, cursor, :history)
101
+ end
102
+ found
103
+ end
104
+
105
+ def thread_active?(parent)
106
+ latest = parent['latest_reply']
107
+ return newer?(latest) if latest
108
+
109
+ parent['reply_count'].to_i.positive?
110
+ end
111
+
112
+ def thread_change(seed, root, require_participation: false, full_first: false)
113
+ full = page_through(:replies, channel: seed.channel_id, timestamp: root) if full_first
114
+ recent = full || page_through(:replies, channel: seed.channel_id, timestamp: root, oldest: @since_ts)
115
+ fresh = after_cutoff(recent)
116
+ # A new top-level post without replies belongs in its channel/DM
117
+ # window, not in a synthetic one-message thread.
118
+ return if fresh.none? { |row| row['ts'] != root }
119
+
120
+ full ||= page_through(:replies, channel: seed.channel_id, timestamp: root)
121
+ return if require_participation && full.none? { |row| row['user'] == @self_id }
122
+
123
+ context = previous_messages(full, parent_ts: root)
124
+ change(seed, 'thread', root, fresh, context)
125
+ rescue ApiError => e
126
+ raise unless e.code == :thread_not_found
127
+ end
128
+
129
+ # Slack's thread view exposes unread followed threads, not a complete
130
+ # subscriptions cursor. Union the first page, and verify participation.
131
+ def subscribed_changes(existing, local)
132
+ response = subscribed_view
133
+ return [] unless response
134
+
135
+ known = existing.map { |change| [change.conversation.channel_id, change.conversation.thread_ts] }
136
+ known.concat(local.flat_map do |(_name, channel), entries|
137
+ entries.map { |_workspace, hit| [channel, hit.thread_ts || hit.ts] }
138
+ end)
139
+ response.fetch('threads', []).filter_map do |item|
140
+ root = item['root_msg'] || {}
141
+ channel = root['channel']
142
+ timestamp = root['thread_ts'] || root['ts']
143
+ next unless channel && timestamp && !known.include?([channel, timestamp])
144
+
145
+ seed = subscription_seed(channel, timestamp, root)
146
+ next unless seed
147
+
148
+ thread_change(seed, timestamp, require_participation: true)
149
+ end
150
+ end
151
+
152
+ def subscribed_view
153
+ @runner.threads_api(@workspace.name).get_view(limit: 100)
154
+ rescue ApiError => e
155
+ # Only unsupported/unauthorized-to-use subscription views are optional.
156
+ # Network, rate-limit and other failures must not look like a clean check-in.
157
+ raise unless OPTIONAL_SUBSCRIPTION_ERRORS.include?(e.code)
158
+
159
+ nil
160
+ end
161
+
162
+ def subscription_seed(channel, timestamp, root)
163
+ info = @api.info(channel: channel).fetch('channel', {})
164
+ type = if info['is_im']
165
+ 'im'
166
+ elsif info['is_mpim']
167
+ 'mpim'
168
+ else
169
+ 'channel'
170
+ end
171
+ name = type == 'im' ? info['user'] : info['name']
172
+ return unless name
173
+
174
+ Models::SearchResult.new(ts: timestamp, user_id: @self_id, username: nil, text: root['text'].to_s,
175
+ channel_id: channel, channel_name: name, channel_type: type,
176
+ thread_ts: timestamp, reply_count: 0, permalink: nil, files: [])
177
+ end
178
+
179
+ def dm_changes(hits, claimed)
180
+ seed = hits.first
181
+ probe = @api.history(channel: seed.channel_id, oldest: @since_ts, limit: 1)
182
+ recent = if probe['has_more']
183
+ page_through(:history, channel: seed.channel_id, oldest: @since_ts)
184
+ else
185
+ probe.fetch('messages', [])
186
+ end
187
+ fresh = after_cutoff(recent).reject { |row| claimed[row['ts']] }
188
+ return [] if fresh.empty?
189
+
190
+ earlier = if @context.zero?
191
+ []
192
+ else
193
+ @api.history(channel: seed.channel_id, latest: @since_ts,
194
+ inclusive: true, limit: @context).fetch('messages', [])
195
+ end
196
+ context = previous_messages(earlier)
197
+ [change(seed, seed.channel_type, nil, fresh, context)]
198
+ end
199
+
200
+ def channel_changes(hits, claimed)
201
+ recent = hits.select { |hit| top_level?(hit) && newer?(hit.ts) }
202
+ return [] if recent.empty?
203
+
204
+ windows = SentConversations.new(runner: @runner, start_date: @since.to_date, end_date: Date.today,
205
+ before: @before, after_minutes: @after_minutes, max: 0).collect(
206
+ recent.map { |hit| [@workspace, hit] }
207
+ )
208
+ own_timestamps = recent.to_h { |hit| [hit.ts, true] }
209
+ windows.filter_map do |window|
210
+ next unless window.type == 'channel'
211
+
212
+ fresh = window.messages.select { |msg| own_timestamps[msg.ts] && !claimed[msg.ts] }
213
+ next if fresh.empty?
214
+
215
+ context = previous_messages(window.messages)
216
+ change(hits.first, 'channel', nil, fresh, context)
217
+ end
218
+ end
219
+
220
+ def top_level?(hit)
221
+ hit.thread_ts.nil? || hit.thread_ts == hit.ts
222
+ end
223
+
224
+ def previous_messages(raw, parent_ts: nil)
225
+ older = raw.select { |row| row_timestamp(row) && !newer?(row_timestamp(row)) }
226
+ older = older.last(@context) unless @context.zero?
227
+ older = [] if @context.zero?
228
+ return older unless parent_ts && @context.positive?
229
+
230
+ parent = raw.find { |row| row_timestamp(row) == parent_ts }
231
+ parent && !newer?(parent_ts) ? ([parent] + older.reject { |row| row == parent }.last(@context - 1)) : older
232
+ end
233
+
234
+ # Counts and last speaker are calculated before --max hides any output.
235
+ def change(seed, type, root, fresh, context)
236
+ messages = normalize(context + fresh, seed.channel_id)
237
+ new_ts = fresh.map { |row| row_timestamp(row) }.uniq
238
+ new_messages = messages.select { |message| new_ts.include?(message.ts) }
239
+ return if new_messages.empty?
240
+
241
+ kept = @max.zero? ? messages : messages.last(@max)
242
+ conversation = Conversation.new(
243
+ workspace: @workspace, channel_id: seed.channel_id, channel_name: seed.channel_name,
244
+ channel_type: seed.channel_type, type: type, thread_ts: root, first_sent_ts: seed.ts,
245
+ messages: kept, last_speaker_is_me: new_messages.last.user_id == @self_id,
246
+ dropped_messages: messages.size - kept.size, self_user_id: @self_id,
247
+ self_username: seed.username
248
+ )
249
+ others = new_messages.count { |message| message.user_id != @self_id }
250
+ Change.new(conversation: conversation, new_timestamps: new_ts,
251
+ new_count: new_messages.size, new_from_others: others)
252
+ end
253
+
254
+ def normalize(raw, channel_id)
255
+ raw.map { |row| row.is_a?(Models::Message) ? row : Models::Message.from_api(row, channel_id: channel_id) }
256
+ .uniq(&:ts).sort_by { |message| BigDecimal(message.ts) }
257
+ end
258
+
259
+ def after_cutoff(raw)
260
+ raw.select { |row| row['ts'] && newer?(row['ts']) && (row['user'] || row['bot_id'] || row['username']) }
261
+ end
262
+
263
+ def row_timestamp(row)
264
+ row.is_a?(Models::Message) ? row.ts : row['ts']
265
+ end
266
+
267
+ def newer?(timestamp)
268
+ BigDecimal(timestamp) > BigDecimal(@since_ts)
269
+ end
270
+
271
+ def page_through(endpoint, **params)
272
+ messages = []
273
+ cursor = nil
274
+ loop do
275
+ response = @api.public_send(endpoint, **params, limit: 200, cursor: cursor)
276
+ messages.concat(response.fetch('messages', []))
277
+ break unless response['has_more']
278
+
279
+ cursor = next_cursor!(response, cursor, endpoint)
280
+ end
281
+ messages
282
+ end
283
+
284
+ def next_cursor!(response, previous, endpoint)
285
+ cursor = response.dig('response_metadata', 'next_cursor').to_s
286
+ raise ApiError, "conversations.#{endpoint} has_more without a new cursor" if cursor.empty? || cursor == previous
287
+
288
+ cursor
289
+ end
290
+ end
291
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
292
+ end
293
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Resolves sent-conversation destinations while retaining raw channel IDs
6
+ # and names in JSON. Group DMs need member lookup: their Slack name is an
7
+ # opaque mpdm-* slug, not a user ID that MentionReplacer can resolve.
8
+ class SentChannelLabel
9
+ USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
10
+
11
+ def initialize(runner:)
12
+ @runner = runner
13
+ @labels = {}
14
+ end
15
+
16
+ # rubocop:disable Metrics/ParameterLists
17
+ def label(workspace:, type:, name:, channel_id:, self_user_id: nil, self_username: nil)
18
+ key = [workspace.name, channel_id, self_user_id]
19
+ @labels[key] ||= if type == 'mpim'
20
+ group_label(workspace, channel_id, name, self_user_id, self_username)
21
+ else
22
+ @runner.search_formatter.channel_label_for(type, name, workspace)
23
+ end
24
+ end
25
+
26
+ # rubocop:enable Metrics/ParameterLists
27
+
28
+ private
29
+
30
+ def group_label(workspace, channel_id, name, self_user_id, self_username)
31
+ names = group_member_names(workspace, channel_id, self_user_id)
32
+ return "@#{names.join(', ')}" if names.any?
33
+
34
+ fallback_group_label(name, self_username)
35
+ rescue ApiError
36
+ fallback_group_label(name, self_username)
37
+ end
38
+
39
+ def group_member_names(workspace, channel_id, self_user_id)
40
+ members = @runner.conversations_api(workspace.name).info(channel: channel_id).dig('channel', 'members') || []
41
+ names = members.reject { |id| id == self_user_id }.map { |id| resolve_name(workspace, id) }
42
+ names.any? { |value| value.match?(USER_ID_PATTERN) } ? [] : names
43
+ end
44
+
45
+ def resolve_name(workspace, user_id)
46
+ Services::UserLookup.new(cache_store: @runner.cache_store, workspace: workspace,
47
+ api_client: @runner.api_client).resolve_name_or_bot(user_id) || user_id
48
+ end
49
+
50
+ def fallback_group_label(name, self_username)
51
+ handles = name.to_s.sub(/\Ampdm-/, '').sub(/-\d+\z/, '').split('--')
52
+ people = handles.reject { |handle| normalized_handle(handle) == normalized_handle(self_username) }
53
+ people = handles if people.empty?
54
+ "@#{people.map { |handle| pretty_handle(handle) }.join(', ')}"
55
+ end
56
+
57
+ def normalized_handle(handle)
58
+ handle.to_s.downcase.gsub(/[^a-z0-9]/, '')
59
+ end
60
+
61
+ def pretty_handle(handle)
62
+ handle.tr('._-', ' ').split.map(&:capitalize).join(' ')
63
+ end
64
+ end
65
+ end
66
+ end