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,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Slk
6
+ module Services
7
+ # Expands indexed from:me hits into bounded channel windows, full DM
8
+ # histories and full threads. Search is the seed, not the context source.
9
+ # rubocop:disable Metrics/ClassLength
10
+ class SentConversations
11
+ Conversation = Data.define(:workspace, :channel_id, :channel_name, :channel_type, :type, :thread_ts,
12
+ :first_sent_ts, :messages, :last_speaker_is_me, :dropped_messages,
13
+ :self_user_id, :self_username)
14
+ DEFAULT_BEFORE = 5
15
+ DEFAULT_AFTER_MINUTES = 30
16
+ DEFAULT_MAX = 200
17
+
18
+ # A date span plus three independent window/output limits.
19
+ # rubocop:disable Metrics/ParameterLists
20
+ def initialize(runner:, start_date:, end_date:, before: DEFAULT_BEFORE,
21
+ after_minutes: DEFAULT_AFTER_MINUTES, max: DEFAULT_MAX)
22
+ @runner = runner
23
+ @start_date = start_date
24
+ @end_date = end_date
25
+ @before = before
26
+ @after_seconds = after_minutes * 60
27
+ @max = max
28
+ end
29
+ # rubocop:enable Metrics/ParameterLists
30
+
31
+ def collect(entries)
32
+ @self_ids = entries.to_h { |workspace, result| [workspace.name, self_id(workspace, result)] }
33
+ groups = entries.group_by { |workspace, result| [workspace.name, result.channel_id] }
34
+ conversations = groups.flat_map { |_key, group| collect_channel(group) }
35
+ # A thread parent can also appear in a channel window / DM history.
36
+ # Thread owns the message so the same (channel, ts) is never repeated.
37
+ ordered_conversations(deduplicate(conversations))
38
+ end
39
+
40
+ private
41
+
42
+ def ordered_conversations(conversations)
43
+ conversations.sort_by do |conversation|
44
+ # Keep Slack's fractional timestamp precise; identity breaks ties regardless of input order or platform.
45
+ [BigDecimal(conversation.first_sent_ts), conversation.workspace.name.to_s,
46
+ conversation.channel_id.to_s, conversation.thread_ts.to_s]
47
+ end
48
+ end
49
+
50
+ # Partitioning a channel needs both the reply roots and unthreaded hits.
51
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
52
+ def collect_channel(group)
53
+ workspace, seed = group.first
54
+ hits = group.map(&:last)
55
+ @self_id = @self_ids[workspace.name]
56
+ @api = @runner.conversations_api(workspace.name)
57
+ threaded = hits.select { |hit| hit.thread_ts && hit.thread_ts != hit.ts }.group_by(&:thread_ts)
58
+ ordinary = hits.reject { |hit| threaded.key?(hit.thread_ts) || threaded.key?(hit.ts) }
59
+ threads = threaded.map do |root, results|
60
+ parent_hit = hits.find { |hit| hit.ts == root }
61
+ thread_hits = parent_hit ? [parent_hit, *results] : results
62
+ thread_conversation(workspace, seed, root, thread_hits)
63
+ end
64
+ spans = if seed.dm?
65
+ ordinary.empty? ? [] : [dm_conversation(workspace, seed, ordinary)]
66
+ else
67
+ channel_conversations(workspace, seed, ordinary)
68
+ end
69
+ threads + spans
70
+ end
71
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
72
+
73
+ def self_id(workspace, result)
74
+ return result.user_id if result.user_id.to_s.match?(/\A[UW][A-Z0-9]+\z/i)
75
+
76
+ cached = @runner.cache_store.get_meta(workspace.name, 'self_user_id')
77
+ return cached if cached
78
+
79
+ id = @runner.client_api(workspace.name).auth_test['user_id']
80
+ raise ApiError, "Cannot identify sender in #{workspace.name}" unless id
81
+
82
+ @runner.cache_store.set_meta(workspace.name, 'self_user_id', id)
83
+ id
84
+ end
85
+
86
+ def thread_conversation(workspace, seed, root, hits)
87
+ raw = thread_messages(seed.channel_id, root)
88
+ build(workspace, seed, 'thread', root, hits, raw)
89
+ end
90
+
91
+ def dm_conversation(workspace, seed, hits)
92
+ start_at = @start_date.to_time.to_i
93
+ # Slack's latest is exclusive; +1 includes messages in this second.
94
+ end_at = [(@end_date + 1).to_time.to_i, Time.now.to_i + 1].min
95
+ raw = page_through(:history, channel: seed.channel_id, oldest: start_at.to_s, latest: end_at.to_s)
96
+ build(workspace, seed, seed.channel_type, nil, hits, raw, expand_threads: true)
97
+ end
98
+
99
+ def channel_conversations(workspace, seed, hits)
100
+ merge_windows(hits).map { |window| channel_window(workspace, seed, window) }
101
+ end
102
+
103
+ def channel_window(workspace, seed, window)
104
+ preceding = preceding_messages(seed.channel_id, window.first.ts)
105
+ following = following_messages(seed.channel_id, window)
106
+ build(workspace, seed, 'channel', nil, window, preceding + following, expand_threads: true)
107
+ end
108
+
109
+ def preceding_messages(channel_id, timestamp)
110
+ return [] if @before.zero?
111
+
112
+ @api.history(channel: channel_id, latest: timestamp, limit: @before).fetch('messages', [])
113
+ end
114
+
115
+ def following_messages(channel_id, window)
116
+ return [] if @after_seconds.zero?
117
+
118
+ end_at = (BigDecimal(window.last.ts) + @after_seconds).to_s('F')
119
+ page_through(:history, channel: channel_id, oldest: window.first.ts, latest: end_at, inclusive: true)
120
+ end
121
+
122
+ def merge_windows(hits)
123
+ hits.sort_by { |hit| hit.ts.to_f }.each_with_object([]) do |hit, windows|
124
+ if windows.any? && hit.ts.to_f <= windows.last.last.ts.to_f + @after_seconds
125
+ windows.last << hit
126
+ else
127
+ windows << [hit]
128
+ end
129
+ end
130
+ end
131
+
132
+ def thread_messages(channel_id, root)
133
+ page_through(:replies, channel: channel_id, timestamp: root)
134
+ end
135
+
136
+ # Cursor follow-up is necessary even when the first page is full.
137
+ # rubocop:disable Metrics/MethodLength
138
+ def page_through(endpoint, **params)
139
+ messages = []
140
+ cursor = nil
141
+ loop do
142
+ response = @api.public_send(endpoint, **params, limit: 200, cursor: cursor)
143
+ messages.concat(response.fetch('messages', []))
144
+ break unless response['has_more']
145
+
146
+ next_cursor = response.dig('response_metadata', 'next_cursor').to_s
147
+ if next_cursor.empty? || next_cursor == cursor
148
+ raise ApiError, "conversations.#{endpoint} has_more without a new cursor"
149
+ end
150
+
151
+ cursor = next_cursor
152
+ end
153
+ messages
154
+ end
155
+ # rubocop:enable Metrics/MethodLength
156
+
157
+ # Keep the search hits as a fallback at Slack's exclusive history bounds.
158
+ # rubocop:disable Metrics/ParameterLists
159
+ def build(workspace, seed, type, root, hits, raw, expand_threads: false)
160
+ # Search's own hits ensure an indexed sent message cannot disappear just
161
+ # because history omits thread replies or returns a window boundary.
162
+ raw += hits.map { |hit| search_message(hit) }
163
+ raw = expand_own_threads(raw, seed, hits) if expand_threads
164
+ messages = normalize(raw, seed.channel_id)
165
+ last_is_me = messages.last&.user_id == @self_id
166
+ Conversation.new(workspace: workspace, channel_id: seed.channel_id, channel_name: seed.channel_name,
167
+ channel_type: seed.channel_type, type: type, thread_ts: root, first_sent_ts: hits.first.ts,
168
+ messages: messages, last_speaker_is_me: last_is_me, dropped_messages: 0,
169
+ self_user_id: @self_id, self_username: hits.first.username)
170
+ end
171
+ # rubocop:enable Metrics/ParameterLists
172
+
173
+ def search_message(hit)
174
+ { 'ts' => hit.ts, 'user' => @self_id, 'username' => hit.username, 'text' => hit.text,
175
+ 'thread_ts' => hit.thread_ts, 'reply_count' => hit.reply_count }
176
+ end
177
+
178
+ def expand_own_threads(raw, seed, hits)
179
+ own_roots(raw, seed, hits).each { |root| raw.concat(thread_messages(seed.channel_id, root)) }
180
+ raw
181
+ end
182
+
183
+ def own_roots(raw, seed, hits)
184
+ # Five preceding channel messages may include an old post of ours;
185
+ # don't expand its unrelated thread into today's conversation.
186
+ earliest = seed.dm? ? @start_date.to_time.to_i : hits.first.ts.to_f
187
+ raw.filter_map { |message| message['ts'] if own_thread_root?(message, earliest) }.uniq
188
+ end
189
+
190
+ def own_thread_root?(message, earliest)
191
+ message['user'] == @self_id && message['reply_count'].to_i.positive? &&
192
+ message['ts'].to_f >= earliest
193
+ end
194
+
195
+ def normalize(raw, channel_id)
196
+ raw.select { |message| readable?(message) }
197
+ .uniq { |message| message['ts'] }
198
+ .sort_by { |message| message['ts'].to_f }
199
+ .map { |message| Models::Message.from_api(message, channel_id: channel_id) }
200
+ end
201
+
202
+ def readable?(message)
203
+ message['ts'] && (message['user'] || message['bot_id'] || message['username'])
204
+ end
205
+
206
+ def deduplicate(conversations)
207
+ seen = {}
208
+ conversations.filter_map { |conversation| unique_conversation(conversation, seen) }
209
+ end
210
+
211
+ # Global dedupe happens before --max so a duplicate cannot consume a slot.
212
+ # rubocop:disable Metrics/AbcSize
213
+ def unique_conversation(conversation, seen)
214
+ key = [conversation.workspace.name, conversation.channel_id]
215
+ unique = conversation.messages.reject { |message| seen[[*key, message.ts]] }
216
+ return if unique.empty?
217
+
218
+ unique.each { |message| seen[[*key, message.ts]] = true }
219
+ kept = @max.zero? ? unique : unique.last(@max)
220
+ conversation.with(messages: kept, dropped_messages: unique.size - kept.size,
221
+ last_speaker_is_me: unique.last.user_id == @self_ids[conversation.workspace.name])
222
+ end
223
+ # rubocop:enable Metrics/AbcSize
224
+ end
225
+ # rubocop:enable Metrics/ClassLength
226
+ end
227
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'date'
5
+ require 'time'
6
+
7
+ module Slk
8
+ module Support
9
+ # A stateless, exact timestamp for sent-conversation change detection.
10
+ class CheckInTime
11
+ RELATIVE = /\A(\d+)([mhd])\z/i
12
+ CLOCK = /\A(\d{1,2}):(\d{2})\z/
13
+ EPOCH = /\A\d{9,12}(?:\.\d{1,6})?\z/
14
+ ISO = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\z/
15
+
16
+ def self.parse(value, now: Time.now)
17
+ time = case value
18
+ when RELATIVE then relative(Regexp.last_match, now)
19
+ when CLOCK then clock(Regexp.last_match, now)
20
+ when EPOCH then epoch_time(value)
21
+ when ISO then iso_time(value)
22
+ else raise UsageError, 'Invalid --changed-since time. Use H:MM or HH:MM, ISO, epoch, 90m, 2h, or 1d.'
23
+ end
24
+ raise UsageError, '--changed-since must not be in the future.' if time > now
25
+
26
+ time
27
+ end
28
+
29
+ def self.timestamp(time)
30
+ format('%<seconds>d.%<micros>06d', seconds: time.to_i, micros: time.usec)
31
+ end
32
+
33
+ def self.epoch_time(value)
34
+ Time.at(BigDecimal(value).to_r)
35
+ rescue ArgumentError
36
+ raise UsageError, "Invalid --changed-since time: #{value.inspect}."
37
+ end
38
+
39
+ def self.iso_time(value)
40
+ Date.iso8601(value[0, 10])
41
+ with_seconds = value.sub(/(T\d{2}:\d{2})(?=Z|[+-]\d{2}:\d{2}|\z)/, '\\1:00')
42
+ Time.iso8601(with_seconds)
43
+ rescue ArgumentError
44
+ raise UsageError, "Invalid --changed-since time: #{value.inspect}."
45
+ end
46
+
47
+ def self.relative(match, now)
48
+ amount = match[1].to_i
49
+ raise UsageError, '--changed-since duration must be positive.' if amount.zero?
50
+
51
+ seconds = { 'm' => 60, 'h' => 3600, 'd' => 86_400 }.fetch(match[2].downcase)
52
+ now - (amount * seconds)
53
+ end
54
+
55
+ def self.clock(match, now)
56
+ hour, minute = match.captures.map(&:to_i)
57
+ raise UsageError, 'Invalid --changed-since clock time.' unless hour < 24 && minute < 60
58
+
59
+ day = now.to_date
60
+ time = local_clock(day, hour, minute)
61
+ return time if time <= now
62
+
63
+ local_clock(day - 1, hour, minute)
64
+ end
65
+
66
+ def self.local_clock(day, hour, minute)
67
+ Time.local(day.year, day.month, day.day, hour, minute)
68
+ rescue ArgumentError
69
+ raise UsageError, 'Invalid --changed-since clock time.'
70
+ end
71
+ end
72
+ end
73
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.10.0'
4
+ VERSION = '0.11.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -113,6 +113,10 @@ module Slk
113
113
  autoload :DeactivationScanner, 'slk/services/deactivation_scanner'
114
114
  autoload :StartDateLookup, 'slk/services/start_date_lookup'
115
115
  autoload :StartDateField, 'slk/services/start_date_field'
116
+ autoload :SearchPages, 'slk/services/search_pages'
117
+ autoload :SentConversations, 'slk/services/sent_conversations'
118
+ autoload :SentChanges, 'slk/services/sent_changes'
119
+ autoload :SentChannelLabel, 'slk/services/sent_channel_label'
116
120
  end
117
121
 
118
122
  # Output formatters for messages, durations, and emoji
@@ -130,6 +134,7 @@ module Slk
130
134
  autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
131
135
  autoload :BlockFormatter, 'slk/formatters/block_formatter'
132
136
  autoload :SearchFormatter, 'slk/formatters/search_formatter'
137
+ autoload :SentFormatter, 'slk/formatters/sent_formatter'
133
138
  autoload :SavedItemFormatter, 'slk/formatters/saved_item_formatter'
134
139
  autoload :TextProcessor, 'slk/formatters/text_processor'
135
140
  autoload :ProfileFormatter, 'slk/formatters/profile_formatter'
@@ -152,6 +157,7 @@ module Slk
152
157
  autoload :Catchup, 'slk/commands/catchup'
153
158
  autoload :Activity, 'slk/commands/activity'
154
159
  autoload :Search, 'slk/commands/search'
160
+ autoload :Sent, 'slk/commands/sent'
155
161
  autoload :Preset, 'slk/commands/preset'
156
162
  autoload :Workspaces, 'slk/commands/workspaces'
157
163
  autoload :Cache, 'slk/commands/cache'
@@ -193,6 +199,7 @@ module Slk
193
199
  autoload :TextWrapper, 'slk/support/text_wrapper'
194
200
  autoload :InteractivePrompt, 'slk/support/interactive_prompt'
195
201
  autoload :DateParser, 'slk/support/date_parser'
202
+ autoload :CheckInTime, 'slk/support/check_in_time'
196
203
  autoload :TimeParser, 'slk/support/time_parser'
197
204
  autoload :TimeRangeParser, 'slk/support/time_range_parser'
198
205
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -56,6 +56,7 @@ files:
56
56
  - lib/slk/commands/presence.rb
57
57
  - lib/slk/commands/preset.rb
58
58
  - lib/slk/commands/search.rb
59
+ - lib/slk/commands/sent.rb
59
60
  - lib/slk/commands/ssh_key_manager.rb
60
61
  - lib/slk/commands/status.rb
61
62
  - lib/slk/commands/thread.rb
@@ -82,6 +83,7 @@ files:
82
83
  - lib/slk/formatters/reaction_formatter.rb
83
84
  - lib/slk/formatters/saved_item_formatter.rb
84
85
  - lib/slk/formatters/search_formatter.rb
86
+ - lib/slk/formatters/sent_formatter.rb
85
87
  - lib/slk/formatters/text_processor.rb
86
88
  - lib/slk/models/channel.rb
87
89
  - lib/slk/models/deactivation.rb
@@ -117,6 +119,10 @@ files:
117
119
  - lib/slk/services/profile_builder.rb
118
120
  - lib/slk/services/profile_resolver.rb
119
121
  - lib/slk/services/reaction_enricher.rb
122
+ - lib/slk/services/search_pages.rb
123
+ - lib/slk/services/sent_changes.rb
124
+ - lib/slk/services/sent_channel_label.rb
125
+ - lib/slk/services/sent_conversations.rb
120
126
  - lib/slk/services/setup_wizard.rb
121
127
  - lib/slk/services/start_date_field.rb
122
128
  - lib/slk/services/start_date_lookup.rb
@@ -129,6 +135,7 @@ files:
129
135
  - lib/slk/services/user_matcher.rb
130
136
  - lib/slk/services/user_picker.rb
131
137
  - lib/slk/services/who_target_resolver.rb
138
+ - lib/slk/support/check_in_time.rb
132
139
  - lib/slk/support/date_parser.rb
133
140
  - lib/slk/support/error_logger.rb
134
141
  - lib/slk/support/help_formatter.rb
@@ -164,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
164
171
  - !ruby/object:Gem::Version
165
172
  version: '0'
166
173
  requirements: []
167
- rubygems_version: 4.0.16
174
+ rubygems_version: 4.0.20
168
175
  specification_version: 4
169
176
  summary: A command-line interface for Slack
170
177
  test_files: []