slk 0.9.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
  }
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # How long someone was here: a start date from their Slack profile and the
6
+ # date their account was deactivated.
7
+ #
8
+ # Both ends are softer than they look. The start date is whatever an admin
9
+ # typed into a custom profile field, and the end is the account's `updated`
10
+ # timestamp. Neither is a payroll record, so this rounds to whole months
11
+ # and refuses to imply a precision it does not have.
12
+ Tenure = Data.define(:started_on, :ended_on) do
13
+ # @param started [String, nil] ISO date from the profile field
14
+ # @param ended [Time, nil] deactivation time
15
+ def self.build(started, ended)
16
+ date = parse_date(started)
17
+ return nil unless date
18
+
19
+ new(started_on: date, ended_on: ended ? to_date(ended) : nil)
20
+ end
21
+
22
+ def self.parse_date(value)
23
+ text = value.to_s.strip
24
+ return nil unless /\A\d{4}-\d{2}-\d{2}\z/.match?(text)
25
+
26
+ Date.iso8601(text)
27
+ rescue Date::Error
28
+ nil
29
+ end
30
+
31
+ def self.to_date(time)
32
+ Date.new(time.year, time.month, time.day)
33
+ end
34
+
35
+ # Nil when the account is still active, or when the end predates the
36
+ # start — a start date typed in after the fact can land anywhere, and a
37
+ # negative tenure is a data entry error, not a fact about a person.
38
+ def months
39
+ return nil unless ended_on && ended_on >= started_on
40
+
41
+ ended_on.day < started_on.day ? month_span - 1 : month_span
42
+ end
43
+
44
+ def month_span
45
+ ((ended_on.year - started_on.year) * 12) + (ended_on.month - started_on.month)
46
+ end
47
+ private :month_span
48
+
49
+ # "6y 2mo", "11mo", "<1mo" — whole months only.
50
+ def to_s
51
+ total = months
52
+ return '' unless total
53
+ return '<1mo' if total.zero?
54
+
55
+ years, rest = total.divmod(12)
56
+ [years.positive? ? "#{years}y" : nil, rest.positive? ? "#{rest}mo" : nil].compact.join(' ')
57
+ end
58
+
59
+ # True when the length cannot be worked out: an active account, or an
60
+ # end date that predates the start.
61
+ def unknown? = months.nil?
62
+
63
+ def started = started_on.iso8601
64
+ end
65
+ end
66
+ end
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)
@@ -232,7 +232,7 @@ module Slk
232
232
  end
233
233
 
234
234
  def parse_success_response(response)
235
- result = JSON.parse(response.body)
235
+ result = parse_body(response.body)
236
236
  raise_rate_limit(response) if result['error'] == 'ratelimited'
237
237
  unless result['ok']
238
238
  message = result['error'] || 'Unknown error'
@@ -240,6 +240,17 @@ module Slk
240
240
  end
241
241
 
242
242
  result
243
+ end
244
+
245
+ # Every caller treats a response as a Hash and digs into it. Slack
246
+ # sending a bare array, string or null is not something any of them can
247
+ # act on, so it fails here as an API error rather than as a TypeError
248
+ # somewhere downstream.
249
+ def parse_body(body)
250
+ result = JSON.parse(body)
251
+ return result if result.is_a?(Hash)
252
+
253
+ raise ApiError.new('Unexpected response shape from Slack API', code: :invalid_response)
243
254
  rescue JSON::ParserError
244
255
  raise ApiError.new('Invalid JSON response from Slack API', code: :invalid_json)
245
256
  end
@@ -135,6 +135,19 @@ module Slk
135
135
  end
136
136
  end
137
137
 
138
+ # The meta cache holds everything that is neither a user nor a channel:
139
+ # start dates, the deactivation roster, resolved profiles. "Clear all
140
+ # caches" was not telling the truth while this was left behind.
141
+ def clear_meta_cache(workspace_name = nil)
142
+ if workspace_name
143
+ @meta_cache.delete(workspace_name)
144
+ FileUtils.rm_f(meta_cache_file(workspace_name))
145
+ else
146
+ @meta_cache.clear
147
+ Dir.glob(@paths.cache_file('meta-*.json')).each { |f| FileUtils.rm_f(f) }
148
+ end
149
+ end
150
+
138
151
  # Subteam cache methods
139
152
  def get_subteam(workspace_name, subteam_id)
140
153
  load_subteam_cache(workspace_name)
@@ -199,6 +212,12 @@ module Slk
199
212
  @on_warning&.call("#{cache_type} cache corrupted for #{workspace_name}: #{e.message}. Cache will be rebuilt.")
200
213
  safely_delete_file(file)
201
214
  {}
215
+ rescue SystemCallError, IOError => e
216
+ # An unreadable cache file is the same situation as a corrupt one:
217
+ # carry on without it. Not deleted, because a file we cannot read is
218
+ # one we probably cannot remove either, and it may not be ours.
219
+ @on_warning&.call("#{cache_type} cache unreadable for #{workspace_name}: #{e.message}. Continuing without it.")
220
+ {}
202
221
  end
203
222
 
204
223
  def safely_delete_file(file)
@@ -7,6 +7,8 @@ module Slk
7
7
  module MetaCache
8
8
  module_function
9
9
 
10
+ # A write failure here is deliberately dropped: fetch's caller wants the
11
+ # value, and callers that need to report a cold cache use write directly.
10
12
  def fetch(cache_store, workspace_name, key, ttl: nil, refresh: false)
11
13
  cached = read(cache_store, workspace_name, key, ttl: ttl) unless refresh
12
14
  return cached if cached
@@ -22,10 +24,23 @@ module Slk
22
24
  cache_store.get_meta(workspace_name, key, ttl: ttl)
23
25
  end
24
26
 
27
+ # A cache write that fails must never cost the caller the work it just
28
+ # did — a full or read-only disk means "no cache", not "no answer", and
29
+ # some of these writes sit behind minutes of rate-limited API calls.
30
+ #
31
+ # A nil or false value is treated as nothing to store, since no caller
32
+ # caches a negative this way — the start date lookup caches per-user
33
+ # nils inside a Hash, which is a value like any other.
34
+ #
35
+ # @return [Exception, nil] the write failure, for callers that want to
36
+ # mention it; nil when the write succeeded or there was nothing to do
25
37
  def write(cache_store, workspace_name, key, value)
26
- return unless cache_store && workspace_name && value
38
+ return nil unless cache_store && workspace_name && value
27
39
 
28
40
  cache_store.set_meta(workspace_name, key, value)
41
+ nil
42
+ rescue SystemCallError, IOError => e
43
+ e
29
44
  end
30
45
  end
31
46
  end
@@ -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