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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/lib/slk/api/conversations.rb +6 -2
- data/lib/slk/cli.rb +1 -0
- data/lib/slk/commands/help.rb +2 -0
- data/lib/slk/commands/search.rb +55 -48
- data/lib/slk/commands/sent.rb +288 -0
- data/lib/slk/formatters/attachment_formatter.rb +7 -3
- data/lib/slk/formatters/search_formatter.rb +25 -11
- data/lib/slk/formatters/sent_formatter.rb +193 -0
- data/lib/slk/models/search_result.rb +2 -0
- data/lib/slk/runner.rb +4 -0
- data/lib/slk/services/search_pages.rb +65 -0
- data/lib/slk/services/sent_changes.rb +293 -0
- data/lib/slk/services/sent_channel_label.rb +66 -0
- data/lib/slk/services/sent_conversations.rb +227 -0
- data/lib/slk/support/check_in_time.rb +73 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +7 -0
- metadata +9 -2
|
@@ -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
|
@@ -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
|