slk 0.13.0 → 0.15.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 +28 -0
- data/README.md +21 -0
- data/lib/slk/api/users.rb +19 -5
- data/lib/slk/cli.rb +1 -0
- data/lib/slk/commands/activity.rb +6 -0
- data/lib/slk/commands/catchup.rb +29 -11
- data/lib/slk/commands/debug.rb +15 -4
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/huddles.rb +94 -0
- data/lib/slk/commands/messages.rb +20 -3
- data/lib/slk/commands/thread.rb +1 -2
- data/lib/slk/commands/unread.rb +34 -19
- data/lib/slk/commands/who.rb +22 -2
- data/lib/slk/formatters/huddle_formatter.rb +54 -0
- data/lib/slk/formatters/json_message_formatter.rb +1 -1
- data/lib/slk/formatters/mention_replacer.rb +9 -3
- data/lib/slk/formatters/profile_formatter.rb +9 -0
- data/lib/slk/formatters/profile_rows.rb +14 -1
- data/lib/slk/formatters/search_formatter.rb +4 -0
- data/lib/slk/models/huddle.rb +17 -0
- data/lib/slk/models/profile.rb +16 -1
- data/lib/slk/models/search_result.rb +3 -0
- data/lib/slk/services/activity_enricher.rb +33 -13
- data/lib/slk/services/api_client.rb +58 -2
- data/lib/slk/services/cache_store.rb +13 -2
- data/lib/slk/services/channel_lookup.rb +78 -0
- data/lib/slk/services/group_dm_label.rb +83 -0
- data/lib/slk/services/huddle_channel_label.rb +62 -0
- data/lib/slk/services/huddle_fetch.rb +90 -0
- data/lib/slk/services/huddle_scanner.rb +101 -0
- data/lib/slk/services/huddle_state.rb +41 -0
- data/lib/slk/services/profile_builder.rb +1 -0
- data/lib/slk/services/profile_resolver.rb +1 -1
- data/lib/slk/services/sent_channel_label.rb +4 -0
- data/lib/slk/services/target_resolver.rb +2 -13
- data/lib/slk/services/unread_marker.rb +8 -5
- data/lib/slk/services/workspace_fanout.rb +28 -0
- data/lib/slk/support/group_dm_name.rb +39 -0
- data/lib/slk/support/user_resolver.rb +21 -5
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +13 -0
- metadata +12 -1
|
@@ -109,14 +109,20 @@ module Slk
|
|
|
109
109
|
response = Api::Conversations.new(@api, workspace).info(channel: channel_id)
|
|
110
110
|
return nil unless response['ok'] && response['channel']
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
@cache.set_channel(workspace.name, name, channel_id) if name
|
|
114
|
-
name
|
|
112
|
+
remember_channel_name(workspace, channel_id, response['channel'])
|
|
115
113
|
rescue ApiError => e
|
|
116
114
|
@on_debug&.call("Channel lookup failed for #{channel_id}: #{e.message}")
|
|
117
115
|
nil
|
|
118
116
|
end
|
|
119
117
|
|
|
118
|
+
# Group DMs stay out of the name cache: a named one can be renamed or
|
|
119
|
+
# share a real channel's name, and an mpdm-* slug isn't a name.
|
|
120
|
+
def remember_channel_name(workspace, channel_id, channel)
|
|
121
|
+
name = channel['name']
|
|
122
|
+
@cache.set_channel(workspace.name, name, channel_id) if name && !channel['is_mpim']
|
|
123
|
+
name
|
|
124
|
+
end
|
|
125
|
+
|
|
120
126
|
def lookup_subteam_handle(workspace, subteam_id)
|
|
121
127
|
cached = @cache.get_subteam(workspace.name, subteam_id)
|
|
122
128
|
return cached if cached
|
|
@@ -25,6 +25,7 @@ module Slk
|
|
|
25
25
|
render_header(profile)
|
|
26
26
|
return emit_rows(@rows.external(profile)) if profile.external?
|
|
27
27
|
|
|
28
|
+
emit_huddle(profile)
|
|
28
29
|
render_section('Contact information', @rows.contact(profile))
|
|
29
30
|
render_section('People', @rows.people(profile))
|
|
30
31
|
render_section('About me', @rows.about(profile))
|
|
@@ -70,6 +71,14 @@ module Slk
|
|
|
70
71
|
@output.gray("external — #{profile.home_team_name || 'external workspace'}")
|
|
71
72
|
end
|
|
72
73
|
|
|
74
|
+
def emit_huddle(profile)
|
|
75
|
+
rows = @rows.huddle(profile)
|
|
76
|
+
return if rows.reject { |_, value| value.nil? || value.to_s.empty? }.empty?
|
|
77
|
+
|
|
78
|
+
emit_rows(rows)
|
|
79
|
+
@output.puts
|
|
80
|
+
end
|
|
81
|
+
|
|
73
82
|
def render_section(title, rows)
|
|
74
83
|
return if rows.reject { |_, v| v.nil? || v.to_s.empty? }.empty?
|
|
75
84
|
|
|
@@ -21,11 +21,16 @@ module Slk
|
|
|
21
21
|
def base(profile)
|
|
22
22
|
[
|
|
23
23
|
['Presence', profile.presence_label],
|
|
24
|
+
['Huddle', huddle_text(profile)],
|
|
24
25
|
['Status', status_text(profile)],
|
|
25
26
|
['Local', local_time(profile)]
|
|
26
27
|
]
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
def huddle(profile)
|
|
31
|
+
[['Huddle', huddle_text(profile)]]
|
|
32
|
+
end
|
|
33
|
+
|
|
29
34
|
def people(profile)
|
|
30
35
|
profile.people_fields.flat_map do |field|
|
|
31
36
|
field.user_ids.map { |uid| [field.label, @fields.render_user_reference(uid, profile)] }
|
|
@@ -39,7 +44,7 @@ module Slk
|
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def external(profile)
|
|
42
|
-
contact(profile) + [['Workspace', profile.home_team_name]]
|
|
47
|
+
contact(profile) + [['Workspace', profile.home_team_name]] + huddle(profile)
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
private
|
|
@@ -48,6 +53,14 @@ module Slk
|
|
|
48
53
|
field.label.casecmp('Title').zero? && field.value.to_s == profile.title.to_s
|
|
49
54
|
end
|
|
50
55
|
|
|
56
|
+
def huddle_text(profile)
|
|
57
|
+
label = profile.huddle_label
|
|
58
|
+
return nil unless label
|
|
59
|
+
return label if profile.huddle_channel.to_s.empty?
|
|
60
|
+
|
|
61
|
+
"#{label} · #{profile.huddle_channel}"
|
|
62
|
+
end
|
|
63
|
+
|
|
51
64
|
def status_text(profile)
|
|
52
65
|
return nil if profile.status_text.empty? && profile.status_emoji.empty?
|
|
53
66
|
|
|
@@ -37,7 +37,11 @@ module Slk
|
|
|
37
37
|
channel_label_for(result.channel_type, result.channel_name, workspace)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Named group DMs show as #name; their name is not a user ID to resolve.
|
|
40
41
|
def channel_label_for(type, name, workspace)
|
|
42
|
+
named = type == 'mpim' && Support::GroupDmName.label(name)
|
|
43
|
+
return named if named
|
|
44
|
+
|
|
41
45
|
if %w[im mpim].include?(type)
|
|
42
46
|
@mentions.replace("<@#{name}>", workspace)
|
|
43
47
|
else
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
HuddleParticipant = Data.define(:id, :name)
|
|
6
|
+
|
|
7
|
+
# One active huddle. `channel` is a display label filled in after the
|
|
8
|
+
# scan (`#name`, `DM`, `group DM`); `channel_id` is what Slack sent.
|
|
9
|
+
Huddle = Data.define(:call_id, :channel_id, :channel, :participants) do
|
|
10
|
+
def place
|
|
11
|
+
channel || channel_id
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
WorkspaceHuddles = Data.define(:workspace, :huddles)
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/slk/models/profile.rb
CHANGED
|
@@ -15,8 +15,15 @@ module Slk
|
|
|
15
15
|
:tz, :tz_label, :tz_offset, :start_date,
|
|
16
16
|
:is_admin, :is_owner, :is_bot, :is_external, :deleted,
|
|
17
17
|
:team_id, :home_team_name,
|
|
18
|
-
:presence, :
|
|
18
|
+
:presence, :huddle_state, :huddle_channel_id, :huddle_call_id, :huddle_channel,
|
|
19
|
+
:sections, :custom_fields, :resolved_users
|
|
19
20
|
) do
|
|
21
|
+
# Existing constructors predate huddle fields. Default them so a card
|
|
22
|
+
# without a live read still builds, and so the row stays blank.
|
|
23
|
+
def initialize(huddle_state: nil, huddle_channel_id: nil, huddle_call_id: nil, huddle_channel: nil, **rest)
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
20
27
|
def presence_label
|
|
21
28
|
case presence
|
|
22
29
|
when 'active' then 'Active'
|
|
@@ -24,6 +31,14 @@ module Slk
|
|
|
24
31
|
end
|
|
25
32
|
end
|
|
26
33
|
|
|
34
|
+
# `default_unset` is Slack's empty value, not a state worth printing.
|
|
35
|
+
def huddle_label
|
|
36
|
+
case huddle_state
|
|
37
|
+
when 'in_a_huddle' then 'In a huddle'
|
|
38
|
+
when 'available_for_huddle' then 'Available for a huddle'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
27
42
|
def best_name
|
|
28
43
|
return display_name unless display_name.to_s.empty?
|
|
29
44
|
return real_name unless real_name.to_s.empty?
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Slk
|
|
4
4
|
module Services
|
|
5
|
-
# Enriches activity feed items with resolved user/channel names
|
|
5
|
+
# Enriches activity feed items with resolved user/channel names.
|
|
6
|
+
# Group DMs show as "#name" when named, else "@other, members" (given a
|
|
7
|
+
# GroupDmLabel), never as their mpdm-* slug, and are never cached by name.
|
|
6
8
|
class ActivityEnricher
|
|
7
|
-
def initialize(cache_store:, conversations_api:, on_debug: nil)
|
|
9
|
+
def initialize(cache_store:, conversations_api:, group_dm_label: nil, on_debug: nil)
|
|
8
10
|
@cache = cache_store
|
|
9
11
|
@conversations_api = conversations_api
|
|
12
|
+
@group_dm_label = group_dm_label
|
|
10
13
|
@on_debug = on_debug
|
|
11
14
|
end
|
|
12
15
|
|
|
@@ -40,15 +43,16 @@ module Slk
|
|
|
40
43
|
@cache.get_user(workspace.name, user_id) || user_id
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
# Resolve channel ID to
|
|
46
|
+
# Resolve channel ID to a label (with API fallback). Legacy G ids are
|
|
47
|
+
# group DMs or old private channels; they read "Group DM" if Slack
|
|
48
|
+
# won't say which.
|
|
44
49
|
def resolve_channel(workspace, channel_id, with_hash: true)
|
|
45
50
|
return 'DM' if channel_id.start_with?('D')
|
|
46
|
-
return 'Group DM' if channel_id.start_with?('G')
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
return channel_id unless
|
|
52
|
+
label = channel_label(workspace, channel_id)
|
|
53
|
+
return channel_id.start_with?('G') ? 'Group DM' : channel_id unless label
|
|
50
54
|
|
|
51
|
-
with_hash ?
|
|
55
|
+
with_hash ? label : label.delete_prefix('#')
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
private
|
|
@@ -101,21 +105,37 @@ module Slk
|
|
|
101
105
|
data['channel_name'] = resolve_channel(workspace, channel_id, with_hash: false) if channel_id
|
|
102
106
|
end
|
|
103
107
|
|
|
104
|
-
def
|
|
108
|
+
def channel_label(workspace, channel_id)
|
|
109
|
+
known = @group_dm_label&.known(workspace, channel_id)
|
|
110
|
+
return known if known
|
|
111
|
+
|
|
105
112
|
cached = @cache.get_channel_name(workspace.name, channel_id)
|
|
106
|
-
return cached if cached
|
|
113
|
+
return "##{cached}" if cached
|
|
114
|
+
|
|
115
|
+
fetch_channel_label(workspace, channel_id)
|
|
116
|
+
end
|
|
107
117
|
|
|
118
|
+
def fetch_channel_label(workspace, channel_id)
|
|
108
119
|
response = @conversations_api.info(channel: channel_id)
|
|
109
|
-
|
|
120
|
+
channel = response['channel'] if response['ok']
|
|
121
|
+
return nil unless channel
|
|
122
|
+
return group_dm_label(workspace, channel_id, channel) if channel['is_mpim']
|
|
123
|
+
return nil unless channel['name']
|
|
110
124
|
|
|
111
|
-
name
|
|
112
|
-
|
|
113
|
-
name
|
|
125
|
+
@cache.set_channel(workspace.name, channel['name'], channel_id)
|
|
126
|
+
"##{channel['name']}"
|
|
114
127
|
rescue ApiError => e
|
|
115
128
|
@on_debug&.call("Could not resolve channel #{channel_id}: #{e.message}")
|
|
116
129
|
nil
|
|
117
130
|
end
|
|
118
131
|
|
|
132
|
+
def group_dm_label(workspace, channel_id, channel)
|
|
133
|
+
return @group_dm_label.label_from(workspace, channel_id, channel) if @group_dm_label
|
|
134
|
+
|
|
135
|
+
named = Support::GroupDmName.custom(channel)
|
|
136
|
+
named ? "##{named}" : 'Group DM'
|
|
137
|
+
end
|
|
138
|
+
|
|
119
139
|
def debug_missing(item_type, missing_data)
|
|
120
140
|
@on_debug&.call("Could not enrich #{item_type} item - missing #{missing_data} data")
|
|
121
141
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'stringio'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
|
|
3
6
|
module Slk
|
|
4
7
|
module Services
|
|
5
8
|
# HTTP client for Slack API with connection pooling
|
|
@@ -24,6 +27,7 @@ module Slk
|
|
|
24
27
|
|
|
25
28
|
def initialize
|
|
26
29
|
@call_count = 0
|
|
30
|
+
@count_mutex = Mutex.new
|
|
27
31
|
@on_request = nil
|
|
28
32
|
@on_response = nil
|
|
29
33
|
@on_request_body = nil
|
|
@@ -32,6 +36,23 @@ module Slk
|
|
|
32
36
|
@rate_resets = {}
|
|
33
37
|
end
|
|
34
38
|
|
|
39
|
+
# A second client with its own connection. Net::HTTP is not safe to
|
|
40
|
+
# share across threads, and one connection can only fetch one roster
|
|
41
|
+
# at a time. Callbacks are copied now, so set them before isolating.
|
|
42
|
+
def isolated
|
|
43
|
+
child = self.class.new
|
|
44
|
+
child.on_request = @on_request
|
|
45
|
+
child.on_response = @on_response
|
|
46
|
+
child.on_request_body = @on_request_body
|
|
47
|
+
child.on_response_body = @on_response_body
|
|
48
|
+
child.share_counter(self)
|
|
49
|
+
child
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def share_counter(parent)
|
|
53
|
+
@counter = parent
|
|
54
|
+
end
|
|
55
|
+
|
|
35
56
|
# Close all cached HTTP connections
|
|
36
57
|
def close
|
|
37
58
|
@http_cache.each_value { |http| safe_close(http) }
|
|
@@ -43,6 +64,7 @@ module Slk
|
|
|
43
64
|
execute_request(method, body: body) do |uri, http|
|
|
44
65
|
request = Net::HTTP::Post.new(uri)
|
|
45
66
|
workspace.headers.each { |k, v| request[k] = v }
|
|
67
|
+
accept_gzip(request)
|
|
46
68
|
request.body = body
|
|
47
69
|
http.request(request)
|
|
48
70
|
end
|
|
@@ -52,6 +74,7 @@ module Slk
|
|
|
52
74
|
execute_request(method, params) do |uri, http|
|
|
53
75
|
request = Net::HTTP::Get.new(uri)
|
|
54
76
|
apply_auth_headers(request, workspace)
|
|
77
|
+
accept_gzip(request)
|
|
55
78
|
http.request(request)
|
|
56
79
|
end
|
|
57
80
|
end
|
|
@@ -62,6 +85,7 @@ module Slk
|
|
|
62
85
|
execute_request(method, body: body) do |uri, http|
|
|
63
86
|
request = Net::HTTP::Post.new(uri)
|
|
64
87
|
apply_auth_headers(request, workspace)
|
|
88
|
+
accept_gzip(request)
|
|
65
89
|
request.set_form_data(params) unless params.empty?
|
|
66
90
|
http.request(request)
|
|
67
91
|
end
|
|
@@ -98,6 +122,7 @@ module Slk
|
|
|
98
122
|
log_request_body(method, body)
|
|
99
123
|
uri = build_uri(method, query_params)
|
|
100
124
|
response, elapsed_ms = timed_request(uri, &)
|
|
125
|
+
decode_body!(response)
|
|
101
126
|
track_rate_limit(method, response)
|
|
102
127
|
log_response(method, response, elapsed_ms)
|
|
103
128
|
log_response_body(method, response.body)
|
|
@@ -157,8 +182,39 @@ module Slk
|
|
|
157
182
|
end
|
|
158
183
|
|
|
159
184
|
def log_request(method)
|
|
160
|
-
|
|
161
|
-
@on_request&.call(method,
|
|
185
|
+
count = tally
|
|
186
|
+
@on_request&.call(method, count)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def tally
|
|
190
|
+
(@counter || self).bump_call_count
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def bump_call_count
|
|
194
|
+
@count_mutex.synchronize { @call_count += 1 }
|
|
195
|
+
end
|
|
196
|
+
public :bump_call_count
|
|
197
|
+
|
|
198
|
+
def accept_gzip(request)
|
|
199
|
+
request['Accept-Encoding'] = 'gzip'
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A users.list page is a large JSON document. Slack compresses it when
|
|
203
|
+
# asked, which is most of the time a roster scan spends on the wire.
|
|
204
|
+
def decode_body!(response)
|
|
205
|
+
return unless gzip_encoded?(response)
|
|
206
|
+
|
|
207
|
+
response.body = inflate_gzip(response.body.to_s)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def gzip_encoded?(response)
|
|
211
|
+
response['Content-Encoding'].to_s.downcase.split(',').map(&:strip).include?('gzip')
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def inflate_gzip(body)
|
|
215
|
+
Zlib::GzipReader.new(StringIO.new(body)).read
|
|
216
|
+
rescue Zlib::Error
|
|
217
|
+
raise ApiError.new('Invalid gzip response from Slack API', code: :invalid_response)
|
|
162
218
|
end
|
|
163
219
|
|
|
164
220
|
def log_request_body(method, body)
|
|
@@ -110,16 +110,23 @@ module Slk
|
|
|
110
110
|
result
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
+
# Group DM mpdm-* slugs are never returned (older versions cached them),
|
|
114
|
+
# so callers fall through to a real label.
|
|
113
115
|
def get_channel_name(workspace_name, channel_id)
|
|
114
116
|
load_channel_cache(workspace_name)
|
|
115
117
|
cache = @channel_cache[workspace_name] || {}
|
|
116
|
-
result = cache.
|
|
118
|
+
result = cache.find { |name, id| id == channel_id && !group_dm_slug?(name) }&.first
|
|
117
119
|
log_cache_access('channel_name', workspace_name, channel_id, result)
|
|
118
120
|
result
|
|
119
121
|
end
|
|
120
122
|
|
|
123
|
+
# Refuses group DM mpdm-* slugs: they are Slack's internal names, not
|
|
124
|
+
# what people type or see. Writing drops any slugs cached before this.
|
|
121
125
|
def set_channel(workspace_name, channel_name, channel_id)
|
|
122
|
-
|
|
126
|
+
return if group_dm_slug?(channel_name)
|
|
127
|
+
|
|
128
|
+
load_channel_cache(workspace_name)
|
|
129
|
+
@channel_cache[workspace_name].reject! { |name, _id| group_dm_slug?(name) }
|
|
123
130
|
@channel_cache[workspace_name][channel_name] = channel_id
|
|
124
131
|
save_channel_cache(workspace_name)
|
|
125
132
|
end
|
|
@@ -184,6 +191,10 @@ module Slk
|
|
|
184
191
|
|
|
185
192
|
private
|
|
186
193
|
|
|
194
|
+
def group_dm_slug?(name)
|
|
195
|
+
name.to_s.match?(Support::GroupDmName::SLUG)
|
|
196
|
+
end
|
|
197
|
+
|
|
187
198
|
def load_user_cache(workspace_name)
|
|
188
199
|
return if @user_cache.key?(workspace_name)
|
|
189
200
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Finds a conversation id from a name typed as `#name`.
|
|
6
|
+
#
|
|
7
|
+
# Order: the channel cache, then every page of the conversations you
|
|
8
|
+
# belong to (users.conversations: public and private channels plus group
|
|
9
|
+
# DMs, so a named group DM such as "ifams review" resolves), then one page
|
|
10
|
+
# of conversations.list for public channels you have not joined. That
|
|
11
|
+
# last step is only one page, as before: the full workspace list runs to
|
|
12
|
+
# thousands of channels at 100 per page.
|
|
13
|
+
#
|
|
14
|
+
# A channel wins over a named group DM with the same name. A group DM is
|
|
15
|
+
# cached only after a full scan finds no channel of that name among yours.
|
|
16
|
+
class ChannelLookup
|
|
17
|
+
MEMBER_TYPES = 'public_channel,private_channel,mpim'
|
|
18
|
+
MAX_PAGES = 50
|
|
19
|
+
|
|
20
|
+
def initialize(runner:, cache_store:)
|
|
21
|
+
@runner = runner
|
|
22
|
+
@cache = cache_store
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param workspace [Models::Workspace]
|
|
26
|
+
# @param name [String] channel or group DM name, without the leading #
|
|
27
|
+
# @return [String, nil] the conversation id, or nil if none matches
|
|
28
|
+
def find_id(workspace, name)
|
|
29
|
+
@cache.get_channel_id(workspace.name, name) || lookup(workspace, name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def lookup(workspace, name)
|
|
35
|
+
channel = member_conversation(workspace, name) || listed_channel(workspace, name)
|
|
36
|
+
return nil unless channel
|
|
37
|
+
|
|
38
|
+
@cache.set_channel(workspace.name, channel['name'], channel['id'])
|
|
39
|
+
channel['id']
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def member_conversation(workspace, name)
|
|
43
|
+
group_dm = nil
|
|
44
|
+
each_member_page(workspace) do |conversations|
|
|
45
|
+
conversations.each do |conversation|
|
|
46
|
+
next unless name_matches?(conversation, name)
|
|
47
|
+
return conversation unless conversation['is_mpim']
|
|
48
|
+
|
|
49
|
+
group_dm ||= conversation
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
group_dm
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def each_member_page(workspace)
|
|
56
|
+
api = @runner.users_api(workspace.name)
|
|
57
|
+
cursor = nil
|
|
58
|
+
MAX_PAGES.times do
|
|
59
|
+
response = api.conversations(cursor: cursor, types: MEMBER_TYPES, exclude_archived: true)
|
|
60
|
+
yield response['channels'] || []
|
|
61
|
+
cursor = response.dig('response_metadata', 'next_cursor').to_s
|
|
62
|
+
break if cursor.empty?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def listed_channel(workspace, name)
|
|
67
|
+
channels = @runner.conversations_api(workspace.name).list['channels'] || []
|
|
68
|
+
channels.find { |channel| name_matches?(channel, name) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Channel names are lowercase; a group DM's name keeps whatever case
|
|
72
|
+
# it was given, so "#IFAMS Review" and "#ifams review" both find it.
|
|
73
|
+
def name_matches?(conversation, name)
|
|
74
|
+
conversation['name'].to_s.casecmp?(name)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Header label for a group DM in unread and catchup (conversation and
|
|
6
|
+
# thread headers). A named group DM
|
|
7
|
+
# shows its name ("#ifams review"); any other shows the people in it
|
|
8
|
+
# besides you ("@Ada Lovelace, Grace Hopper") rather than the opaque
|
|
9
|
+
# mpdm-* slug. One conversations.info per chat, remembered for the run.
|
|
10
|
+
class GroupDmLabel
|
|
11
|
+
def initialize(runner:, on_debug: nil)
|
|
12
|
+
@runner = runner
|
|
13
|
+
@on_debug = on_debug
|
|
14
|
+
@labels = {}
|
|
15
|
+
@self_ids = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] "#name", "@names", or "#<channel id>" if Slack refuses
|
|
19
|
+
def label(workspace, channel_id)
|
|
20
|
+
@labels[[workspace.name, channel_id]] ||= fetch_label(workspace, channel_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] a label already worked out this run, if any
|
|
24
|
+
def known(workspace, channel_id)
|
|
25
|
+
@labels[[workspace.name, channel_id]]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Same label when the caller already holds conversations.info's channel.
|
|
29
|
+
def label_from(workspace, channel_id, channel)
|
|
30
|
+
@labels[[workspace.name, channel_id]] ||= label_for(workspace, channel) || "##{channel_id}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def fetch_label(workspace, channel_id)
|
|
36
|
+
channel = @runner.conversations_api(workspace.name).info(channel: channel_id)['channel'] || {}
|
|
37
|
+
label_for(workspace, channel) || "##{channel_id}"
|
|
38
|
+
rescue ApiError => e
|
|
39
|
+
@on_debug&.call("Group DM lookup failed for #{channel_id}: #{e.message}")
|
|
40
|
+
"##{channel_id}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def label_for(workspace, channel)
|
|
44
|
+
named = Support::GroupDmName.custom(channel)
|
|
45
|
+
return "##{named}" if named
|
|
46
|
+
|
|
47
|
+
names = member_names(workspace, channel['members'] || [])
|
|
48
|
+
return "@#{names.join(', ')}" if names.any?
|
|
49
|
+
|
|
50
|
+
"##{channel['name']}" unless channel['name'].to_s.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def member_names(workspace, members)
|
|
54
|
+
self_id = self_user_id(workspace)
|
|
55
|
+
others = members.reject { |id| id == self_id }
|
|
56
|
+
others = members if others.empty?
|
|
57
|
+
others.map { |id| user_name(workspace, id) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def user_name(workspace, user_id)
|
|
61
|
+
UserLookup.new(cache_store: @runner.cache_store, workspace: workspace,
|
|
62
|
+
api_client: @runner.api_client, on_debug: @on_debug).resolve_name_or_bot(user_id) || user_id
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Same meta key the sent and who commands use, so one auth.test serves all.
|
|
66
|
+
def self_user_id(workspace)
|
|
67
|
+
@self_ids.fetch(workspace.name) do
|
|
68
|
+
@self_ids[workspace.name] = @runner.cache_store.get_meta(workspace.name, 'self_user_id') ||
|
|
69
|
+
fetch_self_user_id(workspace)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fetch_self_user_id(workspace)
|
|
74
|
+
id = @runner.client_api(workspace.name).auth_test['user_id']
|
|
75
|
+
@runner.cache_store.set_meta(workspace.name, 'self_user_id', id) if id
|
|
76
|
+
id
|
|
77
|
+
rescue ApiError => e
|
|
78
|
+
@on_debug&.call("Could not identify self in #{workspace.name}: #{e.message}")
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Turns a huddle channel id into a display label. A failed lookup is not
|
|
6
|
+
# a failed command: private channels and DMs the token cannot see still
|
|
7
|
+
# belong in the list, just without a name.
|
|
8
|
+
class HuddleChannelLabel
|
|
9
|
+
def initialize(runner:, cache_store:, on_debug: nil)
|
|
10
|
+
@runner = runner
|
|
11
|
+
@cache = cache_store
|
|
12
|
+
@on_debug = on_debug
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def label(workspace, channel_id)
|
|
16
|
+
return nil if channel_id.to_s.empty?
|
|
17
|
+
|
|
18
|
+
cached_label(workspace, channel_id) || fetch_label(workspace, channel_id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def cached_label(workspace, channel_id)
|
|
24
|
+
name = @cache.get_channel_name(workspace.name, channel_id)
|
|
25
|
+
"##{name}" if name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fetch_label(workspace, channel_id)
|
|
29
|
+
label_from(workspace, channel_id, channel_info(workspace, channel_id))
|
|
30
|
+
rescue ApiError => e
|
|
31
|
+
@on_debug&.call("Channel lookup failed for #{channel_id}: #{e.message}")
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def channel_info(workspace, channel_id)
|
|
36
|
+
@runner.conversations_api(workspace.name).info(channel: channel_id)['channel'] || {}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def label_from(workspace, channel_id, channel)
|
|
40
|
+
return 'DM' if channel['is_im']
|
|
41
|
+
return group_label(channel) if channel['is_mpim']
|
|
42
|
+
|
|
43
|
+
named_label(workspace, channel_id, channel['name'])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A named group DM shows its name but stays out of the channel cache:
|
|
47
|
+
# the name can be changed at any time and can match a real channel's
|
|
48
|
+
# name, and the cache maps names to ids, so `#name` would go to the chat.
|
|
49
|
+
def group_label(channel)
|
|
50
|
+
name = Support::GroupDmName.custom(channel)
|
|
51
|
+
name ? "##{name}" : 'group DM'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def named_label(workspace, channel_id, name)
|
|
55
|
+
return nil if name.to_s.empty?
|
|
56
|
+
|
|
57
|
+
@cache.set_channel(workspace.name, name, channel_id)
|
|
58
|
+
"##{name}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|