slk 0.10.0 → 0.11.1

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.
@@ -23,25 +23,38 @@ module Slk
23
23
  # Display a single search result
24
24
  def display_result(result, workspace, options = {})
25
25
  timestamp = @output.blue("[#{format_time(result.timestamp)}]")
26
- channel = @output.cyan(resolve_channel(result, workspace))
26
+ channel = @output.cyan(display_channel_label(result, workspace, options))
27
27
  user = @output.bold("#{resolve_user(result, workspace)}:")
28
28
  text = prepare_text(result.text, workspace, options)
29
29
 
30
- @output.puts "#{timestamp} #{channel} #{user} #{text}"
31
- display_files(result.files) if result.files&.any?
30
+ workspace_label = workspace_prefix(workspace, options)
31
+ @output.puts "#{timestamp} #{workspace_label}#{channel} #{user} #{text}"
32
+ display_files(result.files, workspace, options) if result.files&.any?
32
33
  end
33
34
 
34
- private
35
+ # Channel label used by search timelines and per-channel summaries.
36
+ def channel_label(result, workspace)
37
+ channel_label_for(result.channel_type, result.channel_name, workspace)
38
+ end
35
39
 
36
- def resolve_channel(result, workspace)
37
- if result.dm?
38
- # For DMs, channel_name is a user ID - resolve it
39
- @mentions.replace("<@#{result.channel_name}>", workspace)
40
+ def channel_label_for(type, name, workspace)
41
+ if %w[im mpim].include?(type)
42
+ @mentions.replace("<@#{name}>", workspace)
40
43
  else
41
- "##{result.channel_name}"
44
+ "##{name}"
42
45
  end
43
46
  end
44
47
 
48
+ private
49
+
50
+ def display_channel_label(result, workspace, options)
51
+ options[:channel_label] || channel_label(result, workspace)
52
+ end
53
+
54
+ def workspace_prefix(workspace, options)
55
+ options[:workspace_label] ? "#{@output.cyan("[#{workspace.name}]")} " : ''
56
+ end
57
+
45
58
  def resolve_user(result, workspace)
46
59
  # If we have a username, use it directly
47
60
  return result.username if result.username && !result.username.empty?
@@ -57,9 +70,10 @@ module Slk
57
70
  @text_processor.process(text, workspace, options)
58
71
  end
59
72
 
60
- def display_files(files)
73
+ def display_files(files, workspace, options)
61
74
  files.each do |file|
62
- @output.puts @output.blue("[Image: #{file[:name]}]")
75
+ name = prepare_text(file[:name], workspace, options)
76
+ @output.puts @output.blue("[Image: #{name}]")
63
77
  end
64
78
  end
65
79
 
@@ -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,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Stateless diff of watched sent conversations. Search discovers what to
6
+ # watch; exact-ts history/replies determine what actually changed.
7
+ # Orchestration spans workspace watch sets, thread diffs and output windows.
8
+ # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
9
+ class SentChanges
10
+ Change = Data.define(:conversation, :new_timestamps, :new_count, :new_from_others)
11
+ Conversation = SentConversations::Conversation
12
+ OPTIONAL_SUBSCRIPTION_ERRORS = %i[missing_scope not_allowed_token_type unknown_method].freeze
13
+
14
+ # rubocop:disable Metrics/ParameterLists
15
+ def initialize(runner:, since:, context: 2, max: 200, before: 5, after_minutes: 30)
16
+ @runner = runner
17
+ @since = since
18
+ @since_ts = Support::CheckInTime.timestamp(since)
19
+ @context = context
20
+ @max = max
21
+ @before = before
22
+ @after_minutes = after_minutes
23
+ end
24
+ # rubocop:enable Metrics/ParameterLists
25
+
26
+ def collect(entries, workspaces:)
27
+ groups = entries.group_by { |workspace, hit| [workspace.name, hit.channel_id] }
28
+ changes = workspaces.flat_map { |workspace| collect_workspace(workspace, groups) }
29
+ changes.sort_by do |change|
30
+ conversation = change.conversation
31
+ # Put the most recently active conversation last. Trailing identity
32
+ # keys make ties deterministic across platforms (including Windows).
33
+ [Support::DecimalTimestamp.parse(conversation.messages.last.ts), conversation.workspace.name.to_s,
34
+ conversation.channel_id.to_s, conversation.thread_ts.to_s]
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def collect_workspace(workspace, groups)
41
+ @workspace = workspace
42
+ @api = @runner.conversations_api(workspace.name)
43
+ @self_id = self_id(workspace, groups)
44
+ local = groups.select { |(name, _id), _hits| name == workspace.name }
45
+ changed_threads = local.flat_map { |(_name, _id), entries| thread_changes(entries.map(&:last)) }
46
+ changed_threads.concat(subscribed_changes(changed_threads, local))
47
+ claimed = changed_threads.flat_map { |change| change.conversation.messages.map(&:ts) }.to_h { |ts| [ts, true] }
48
+ other = local.flat_map do |(_name, _id), entries|
49
+ hits = entries.map(&:last)
50
+ hits.first.dm? ? dm_changes(hits, claimed) : channel_changes(hits, claimed)
51
+ end
52
+ changed_threads + other
53
+ end
54
+
55
+ def self_id(workspace, groups)
56
+ entries = groups.find { |(name, _id), rows| name == workspace.name && rows.any? }&.last
57
+ hit = entries&.first&.last
58
+ return hit.user_id if hit&.user_id.to_s.match?(/\A[UW][A-Z0-9]+\z/i)
59
+
60
+ cached = @runner.cache_store.get_meta(workspace.name, 'self_user_id')
61
+ return cached if cached
62
+
63
+ id = @runner.client_api(workspace.name).auth_test['user_id']
64
+ raise ApiError, "Cannot identify sender in #{workspace.name}" unless id
65
+
66
+ @runner.cache_store.set_meta(workspace.name, 'self_user_id', id)
67
+ id
68
+ end
69
+
70
+ def thread_changes(hits)
71
+ metadata = root_metadata(hits)
72
+ hits.map { |hit| [hit.thread_ts || hit.ts, hit] }.uniq(&:first).filter_map do |root, seed|
73
+ parent = metadata[root]
74
+ next if parent && !thread_active?(parent)
75
+
76
+ thread_change(seed, root, full_first: parent && parent['latest_reply'])
77
+ end
78
+ end
79
+
80
+ # Fetch the watched top-level parents in one paginated history scan per
81
+ # channel. Search often omits reply_count; history has latest_reply.
82
+ def root_metadata(hits)
83
+ roots = hits.select { |hit| top_level?(hit) }.map(&:ts).uniq
84
+ return {} if roots.empty?
85
+
86
+ wanted = roots.to_h { |root| [root, true] }
87
+ found = {}
88
+ cursor = nil
89
+ loop do
90
+ response = @api.history(channel: hits.first.channel_id,
91
+ oldest: roots.min_by { |root| Support::DecimalTimestamp.parse(root) },
92
+ inclusive: true, limit: 200, cursor: cursor)
93
+ response.fetch('messages', []).each do |message|
94
+ found[message['ts']] = message if wanted[message['ts']]
95
+ end
96
+ break if found.size == wanted.size || !response['has_more']
97
+
98
+ cursor = next_cursor!(response, cursor, :history)
99
+ end
100
+ found
101
+ end
102
+
103
+ def thread_active?(parent)
104
+ latest = parent['latest_reply']
105
+ return newer?(latest) if latest
106
+
107
+ parent['reply_count'].to_i.positive?
108
+ end
109
+
110
+ def thread_change(seed, root, require_participation: false, full_first: false)
111
+ full = page_through(:replies, channel: seed.channel_id, timestamp: root) if full_first
112
+ recent = full || page_through(:replies, channel: seed.channel_id, timestamp: root, oldest: @since_ts)
113
+ fresh = after_cutoff(recent)
114
+ # A new top-level post without replies belongs in its channel/DM
115
+ # window, not in a synthetic one-message thread.
116
+ return if fresh.none? { |row| row['ts'] != root }
117
+
118
+ full ||= page_through(:replies, channel: seed.channel_id, timestamp: root)
119
+ return if require_participation && full.none? { |row| row['user'] == @self_id }
120
+
121
+ context = previous_messages(full, parent_ts: root)
122
+ change(seed, 'thread', root, fresh, context)
123
+ rescue ApiError => e
124
+ raise unless e.code == :thread_not_found
125
+ end
126
+
127
+ # Slack's thread view exposes unread followed threads, not a complete
128
+ # subscriptions cursor. Union the first page, and verify participation.
129
+ def subscribed_changes(existing, local)
130
+ response = subscribed_view
131
+ return [] unless response
132
+
133
+ known = existing.map { |change| [change.conversation.channel_id, change.conversation.thread_ts] }
134
+ known.concat(local.flat_map do |(_name, channel), entries|
135
+ entries.map { |_workspace, hit| [channel, hit.thread_ts || hit.ts] }
136
+ end)
137
+ response.fetch('threads', []).filter_map do |item|
138
+ root = item['root_msg'] || {}
139
+ channel = root['channel']
140
+ timestamp = root['thread_ts'] || root['ts']
141
+ next unless channel && timestamp && !known.include?([channel, timestamp])
142
+
143
+ seed = subscription_seed(channel, timestamp, root)
144
+ next unless seed
145
+
146
+ thread_change(seed, timestamp, require_participation: true)
147
+ end
148
+ end
149
+
150
+ def subscribed_view
151
+ @runner.threads_api(@workspace.name).get_view(limit: 100)
152
+ rescue ApiError => e
153
+ # Only unsupported/unauthorized-to-use subscription views are optional.
154
+ # Network, rate-limit and other failures must not look like a clean check-in.
155
+ raise unless OPTIONAL_SUBSCRIPTION_ERRORS.include?(e.code)
156
+
157
+ nil
158
+ end
159
+
160
+ def subscription_seed(channel, timestamp, root)
161
+ info = @api.info(channel: channel).fetch('channel', {})
162
+ type = if info['is_im']
163
+ 'im'
164
+ elsif info['is_mpim']
165
+ 'mpim'
166
+ else
167
+ 'channel'
168
+ end
169
+ name = type == 'im' ? info['user'] : info['name']
170
+ return unless name
171
+
172
+ Models::SearchResult.new(ts: timestamp, user_id: @self_id, username: nil, text: root['text'].to_s,
173
+ channel_id: channel, channel_name: name, channel_type: type,
174
+ thread_ts: timestamp, reply_count: 0, permalink: nil, files: [])
175
+ end
176
+
177
+ def dm_changes(hits, claimed)
178
+ seed = hits.first
179
+ probe = @api.history(channel: seed.channel_id, oldest: @since_ts, limit: 1)
180
+ recent = if probe['has_more']
181
+ page_through(:history, channel: seed.channel_id, oldest: @since_ts)
182
+ else
183
+ probe.fetch('messages', [])
184
+ end
185
+ fresh = after_cutoff(recent).reject { |row| claimed[row['ts']] }
186
+ return [] if fresh.empty?
187
+
188
+ earlier = if @context.zero?
189
+ []
190
+ else
191
+ @api.history(channel: seed.channel_id, latest: @since_ts,
192
+ inclusive: true, limit: @context).fetch('messages', [])
193
+ end
194
+ context = previous_messages(earlier)
195
+ [change(seed, seed.channel_type, nil, fresh, context)]
196
+ end
197
+
198
+ def channel_changes(hits, claimed)
199
+ recent = hits.select { |hit| top_level?(hit) && newer?(hit.ts) }
200
+ return [] if recent.empty?
201
+
202
+ windows = SentConversations.new(runner: @runner, start_date: @since.to_date, end_date: Date.today,
203
+ before: @before, after_minutes: @after_minutes, max: 0).collect(
204
+ recent.map { |hit| [@workspace, hit] }
205
+ )
206
+ own_timestamps = recent.to_h { |hit| [hit.ts, true] }
207
+ windows.filter_map do |window|
208
+ next unless window.type == 'channel'
209
+
210
+ fresh = window.messages.select { |msg| own_timestamps[msg.ts] && !claimed[msg.ts] }
211
+ next if fresh.empty?
212
+
213
+ context = previous_messages(window.messages)
214
+ change(hits.first, 'channel', nil, fresh, context)
215
+ end
216
+ end
217
+
218
+ def top_level?(hit)
219
+ hit.thread_ts.nil? || hit.thread_ts == hit.ts
220
+ end
221
+
222
+ def previous_messages(raw, parent_ts: nil)
223
+ older = raw.select { |row| row_timestamp(row) && !newer?(row_timestamp(row)) }
224
+ older = older.last(@context) unless @context.zero?
225
+ older = [] if @context.zero?
226
+ return older unless parent_ts && @context.positive?
227
+
228
+ parent = raw.find { |row| row_timestamp(row) == parent_ts }
229
+ parent && !newer?(parent_ts) ? ([parent] + older.reject { |row| row == parent }.last(@context - 1)) : older
230
+ end
231
+
232
+ # Counts and last speaker are calculated before --max hides any output.
233
+ def change(seed, type, root, fresh, context)
234
+ messages = normalize(context + fresh, seed.channel_id)
235
+ new_ts = fresh.map { |row| row_timestamp(row) }.uniq
236
+ new_messages = messages.select { |message| new_ts.include?(message.ts) }
237
+ return if new_messages.empty?
238
+
239
+ kept = @max.zero? ? messages : messages.last(@max)
240
+ conversation = Conversation.new(
241
+ workspace: @workspace, channel_id: seed.channel_id, channel_name: seed.channel_name,
242
+ channel_type: seed.channel_type, type: type, thread_ts: root, first_sent_ts: seed.ts,
243
+ messages: kept, last_speaker_is_me: new_messages.last.user_id == @self_id,
244
+ dropped_messages: messages.size - kept.size, self_user_id: @self_id,
245
+ self_username: seed.username
246
+ )
247
+ others = new_messages.count { |message| message.user_id != @self_id }
248
+ Change.new(conversation: conversation, new_timestamps: new_ts,
249
+ new_count: new_messages.size, new_from_others: others)
250
+ end
251
+
252
+ def normalize(raw, channel_id)
253
+ raw.map { |row| row.is_a?(Models::Message) ? row : Models::Message.from_api(row, channel_id: channel_id) }
254
+ .uniq(&:ts).sort_by { |message| Support::DecimalTimestamp.parse(message.ts) }
255
+ end
256
+
257
+ def after_cutoff(raw)
258
+ raw.select { |row| row['ts'] && newer?(row['ts']) && (row['user'] || row['bot_id'] || row['username']) }
259
+ end
260
+
261
+ def row_timestamp(row)
262
+ row.is_a?(Models::Message) ? row.ts : row['ts']
263
+ end
264
+
265
+ def newer?(timestamp)
266
+ Support::DecimalTimestamp.parse(timestamp) > Support::DecimalTimestamp.parse(@since_ts)
267
+ end
268
+
269
+ def page_through(endpoint, **params)
270
+ messages = []
271
+ cursor = nil
272
+ loop do
273
+ response = @api.public_send(endpoint, **params, limit: 200, cursor: cursor)
274
+ messages.concat(response.fetch('messages', []))
275
+ break unless response['has_more']
276
+
277
+ cursor = next_cursor!(response, cursor, endpoint)
278
+ end
279
+ messages
280
+ end
281
+
282
+ def next_cursor!(response, previous, endpoint)
283
+ cursor = response.dig('response_metadata', 'next_cursor').to_s
284
+ raise ApiError, "conversations.#{endpoint} has_more without a new cursor" if cursor.empty? || cursor == previous
285
+
286
+ cursor
287
+ end
288
+ end
289
+ # rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
290
+ end
291
+ end