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