slk 0.14.0 → 0.16.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 +30 -0
- data/README.md +8 -3
- data/lib/slk/api/huddles.rb +25 -0
- data/lib/slk/api/users.rb +4 -2
- data/lib/slk/commands/activity.rb +6 -0
- data/lib/slk/commands/catchup.rb +29 -11
- data/lib/slk/commands/debug.rb +38 -5
- data/lib/slk/commands/huddles.rb +2 -1
- 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 +4 -3
- data/lib/slk/formatters/json_message_formatter.rb +1 -1
- data/lib/slk/formatters/mention_replacer.rb +9 -3
- data/lib/slk/formatters/search_formatter.rb +4 -0
- 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 +1 -1
- 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 +9 -1
- data/lib/slk/services/huddle_fetch.rb +10 -1
- data/lib/slk/services/huddle_room.rb +47 -0
- data/lib/slk/services/huddle_room_enricher.rb +88 -0
- data/lib/slk/services/huddle_scanner.rb +12 -6
- data/lib/slk/services/pooled_fanout.rb +59 -0
- 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/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 +7 -0
- metadata +8 -1
|
@@ -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
|
|
@@ -38,11 +38,19 @@ module Slk
|
|
|
38
38
|
|
|
39
39
|
def label_from(workspace, channel_id, channel)
|
|
40
40
|
return 'DM' if channel['is_im']
|
|
41
|
-
return
|
|
41
|
+
return group_label(channel) if channel['is_mpim']
|
|
42
42
|
|
|
43
43
|
named_label(workspace, channel_id, channel['name'])
|
|
44
44
|
end
|
|
45
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
|
+
|
|
46
54
|
def named_label(workspace, channel_id, name)
|
|
47
55
|
return nil if name.to_s.empty?
|
|
48
56
|
|
|
@@ -13,6 +13,7 @@ module Slk
|
|
|
13
13
|
@output = output
|
|
14
14
|
@counts = {}
|
|
15
15
|
@mutex = Mutex.new
|
|
16
|
+
@cache_mutex = Mutex.new
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def call(workspaces)
|
|
@@ -30,7 +31,8 @@ module Slk
|
|
|
30
31
|
def fetch(workspace)
|
|
31
32
|
client = scan_client
|
|
32
33
|
users = Api::Users.new(client, workspace, on_debug: debug_logger)
|
|
33
|
-
HuddleScanner.group(collect(workspace, users))
|
|
34
|
+
huddles = HuddleScanner.group(collect(workspace, users))
|
|
35
|
+
enricher(workspace, client).call(huddles)
|
|
34
36
|
ensure
|
|
35
37
|
close_client(client)
|
|
36
38
|
end
|
|
@@ -49,6 +51,13 @@ module Slk
|
|
|
49
51
|
Models::WorkspaceHuddles.new(workspace: workspace.name, huddles: labeled)
|
|
50
52
|
end
|
|
51
53
|
|
|
54
|
+
def enricher(workspace, client)
|
|
55
|
+
HuddleRoomEnricher.new(
|
|
56
|
+
workspace: workspace, client: client, cache_store: @runner.cache_store,
|
|
57
|
+
cache_mutex: @cache_mutex, on_debug: debug_logger
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
52
61
|
def scan_client
|
|
53
62
|
return @runner.api_client.isolated if @parallel
|
|
54
63
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Fills in what a profile no longer says about a huddle.
|
|
6
|
+
#
|
|
7
|
+
# Slack stopped sending huddle_state_channel_id; profiles carry only the
|
|
8
|
+
# state and the call id. huddles.get maps the call id to its channel and
|
|
9
|
+
# the full participant list, which includes Slack Connect guests who are
|
|
10
|
+
# missing from users.list. A huddle the token cannot see is left as is.
|
|
11
|
+
class HuddleRoom
|
|
12
|
+
Room = Data.define(:channel_id, :participant_ids)
|
|
13
|
+
|
|
14
|
+
def initialize(api_client:, workspace:, on_debug: nil)
|
|
15
|
+
@api = Api::Huddles.new(api_client, workspace)
|
|
16
|
+
@on_debug = on_debug
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Room, nil]
|
|
20
|
+
def lookup(call_id)
|
|
21
|
+
return nil if call_id.to_s.empty?
|
|
22
|
+
|
|
23
|
+
room = @api.get(call_id)
|
|
24
|
+
return nil if room.empty? || room['has_ended']
|
|
25
|
+
|
|
26
|
+
Room.new(channel_id: single_channel(room), participant_ids: Array(room['participants']))
|
|
27
|
+
rescue ApiError => e
|
|
28
|
+
@on_debug&.call("huddles.get #{call_id} failed: #{e.message}")
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A profile's channel id if Slack sent one, else the room's.
|
|
33
|
+
def channel_for(profile)
|
|
34
|
+
return profile.huddle_channel_id unless profile.huddle_channel_id.to_s.empty?
|
|
35
|
+
|
|
36
|
+
lookup(profile.huddle_call_id)&.channel_id
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def single_channel(room)
|
|
42
|
+
channels = Array(room['channels']).reject { |id| id.to_s.empty? }.uniq
|
|
43
|
+
channels.first if channels.one?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Adds what profiles no longer say to one workspace's huddles: the
|
|
6
|
+
# channel, and Slack Connect guests missing from users.list.
|
|
7
|
+
#
|
|
8
|
+
# huddles.get runs once per call id, and users.info once per guest whose
|
|
9
|
+
# name is not cached, both on a small worker pool so a busy workspace
|
|
10
|
+
# does not wait on dozens of requests in a row. Workspaces are scanned
|
|
11
|
+
# concurrently too, so cache reads and writes go through cache_mutex.
|
|
12
|
+
class HuddleRoomEnricher
|
|
13
|
+
WORKERS = 4
|
|
14
|
+
|
|
15
|
+
def initialize(workspace:, client:, cache_store:, cache_mutex:, on_debug: nil)
|
|
16
|
+
@workspace = workspace
|
|
17
|
+
@client = client
|
|
18
|
+
@cache = cache_store
|
|
19
|
+
@cache_mutex = cache_mutex
|
|
20
|
+
@on_debug = on_debug
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(huddles)
|
|
24
|
+
rooms = fan_out(huddles) { |huddle, client| room_for(huddle, client) }
|
|
25
|
+
names = guest_names(guest_ids(huddles, rooms))
|
|
26
|
+
HuddleScanner.sort(huddles.zip(rooms).map { |huddle, room| apply(huddle, room, names) })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def fan_out(items, &)
|
|
32
|
+
PooledFanout.call(items, workers: WORKERS, client: @client, &)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def room_for(huddle, client)
|
|
36
|
+
return nil if huddle.channel_id || huddle.call_id.nil?
|
|
37
|
+
|
|
38
|
+
HuddleRoom.new(api_client: client, workspace: @workspace, on_debug: @on_debug).lookup(huddle.call_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def guest_ids(huddles, rooms)
|
|
42
|
+
huddles.zip(rooms).flat_map do |huddle, room|
|
|
43
|
+
room ? room.participant_ids - huddle.participants.map(&:id) : []
|
|
44
|
+
end.uniq
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def guest_names(ids)
|
|
48
|
+
cached = cached_names(ids)
|
|
49
|
+
missing = ids - cached.keys
|
|
50
|
+
fetched = missing.zip(fan_out(missing) { |id, client| fetch_name(id, client) }).to_h.compact
|
|
51
|
+
remember(fetched)
|
|
52
|
+
cached.merge(fetched)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cached_names(ids)
|
|
56
|
+
@cache_mutex.synchronize { ids.to_h { |id| [id, @cache.get_user(@workspace.name, id)] }.compact }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fetch_name(user_id, client)
|
|
60
|
+
user = Api::Users.new(client, @workspace, on_debug: @on_debug).info(user_id)['user']
|
|
61
|
+
user && Models::User.from_api(user).best_name
|
|
62
|
+
rescue ApiError => e
|
|
63
|
+
@on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def remember(names)
|
|
68
|
+
return if names.empty?
|
|
69
|
+
|
|
70
|
+
@cache_mutex.synchronize do
|
|
71
|
+
names.each { |id, name| @cache.set_user(@workspace.name, id, name) }
|
|
72
|
+
@cache.save_user_cache(@workspace.name)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply(huddle, room, names)
|
|
77
|
+
return huddle unless room
|
|
78
|
+
|
|
79
|
+
known = huddle.participants.map(&:id)
|
|
80
|
+
added = (room.participant_ids - known).map do |id|
|
|
81
|
+
Models::HuddleParticipant.new(id: id, name: names[id] || id)
|
|
82
|
+
end
|
|
83
|
+
huddle.with(channel_id: room.channel_id,
|
|
84
|
+
participants: HuddleScanner.sort_participants(huddle.participants + added))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -26,12 +26,22 @@ module Slk
|
|
|
26
26
|
member.dig('profile', 'huddle_state') == IN_HUDDLE
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def self.sort(huddles)
|
|
30
|
+
huddles.sort_by do |huddle|
|
|
31
|
+
[-huddle.participants.size, huddle.participants.first.name.downcase, huddle.call_id.to_s]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.sort_participants(people)
|
|
36
|
+
people.sort_by { |person| [person.name.downcase, person.id] }
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
def initialize(members)
|
|
30
40
|
@members = members
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
def group
|
|
34
|
-
|
|
44
|
+
self.class.sort(huddles)
|
|
35
45
|
end
|
|
36
46
|
|
|
37
47
|
private
|
|
@@ -67,7 +77,7 @@ module Slk
|
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
def sorted_participants(people)
|
|
70
|
-
people.map { |member| participant(member) }
|
|
80
|
+
self.class.sort_participants(people.map { |member| participant(member) })
|
|
71
81
|
end
|
|
72
82
|
|
|
73
83
|
def participant(member)
|
|
@@ -92,10 +102,6 @@ module Slk
|
|
|
92
102
|
text = value.to_s
|
|
93
103
|
text.empty? ? nil : text
|
|
94
104
|
end
|
|
95
|
-
|
|
96
|
-
def sort_key(huddle)
|
|
97
|
-
[-huddle.participants.size, huddle.participants.first.name.downcase, huddle.call_id.to_s]
|
|
98
|
-
end
|
|
99
105
|
end
|
|
100
106
|
end
|
|
101
107
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Runs a block per item on a few worker threads, each with its own
|
|
6
|
+
# connection, and returns the results in the original order.
|
|
7
|
+
#
|
|
8
|
+
# Net::HTTP is not safe to share across threads, so every worker gets an
|
|
9
|
+
# isolated client and closes it when the queue is empty. A client that
|
|
10
|
+
# cannot isolate (a test double) runs the items one at a time on itself.
|
|
11
|
+
# A failure waits for the other workers, then raises.
|
|
12
|
+
module PooledFanout
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def call(items, workers:, client:, &block)
|
|
16
|
+
return items.map { |item| block.call(item, client) } unless pooled?(items, workers, client)
|
|
17
|
+
|
|
18
|
+
queue = Queue.new
|
|
19
|
+
items.each_with_index { |item, index| queue << [item, index] }
|
|
20
|
+
queue.close
|
|
21
|
+
results = Array.new(items.size)
|
|
22
|
+
threads = Array.new([workers, items.size].min) { start(queue, results, client, &block) }
|
|
23
|
+
wait_for_all(threads)
|
|
24
|
+
threads.each(&:value)
|
|
25
|
+
results
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Thread#join re-raises a worker's exception at once, which would
|
|
29
|
+
# return before the other workers close their connections.
|
|
30
|
+
def wait_for_all(threads)
|
|
31
|
+
threads.each do |thread|
|
|
32
|
+
thread.join
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pooled?(items, workers, client)
|
|
39
|
+
workers > 1 && items.size > 1 && client.respond_to?(:isolated)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start(queue, results, client, &block)
|
|
43
|
+
worker_client = client.isolated
|
|
44
|
+
Thread.new { drain(queue, results, worker_client, &block) }.tap do |thread|
|
|
45
|
+
thread.report_on_exception = false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def drain(queue, results, client, &block)
|
|
50
|
+
while (job = queue.pop)
|
|
51
|
+
item, index = job
|
|
52
|
+
results[index] = block.call(item, client)
|
|
53
|
+
end
|
|
54
|
+
ensure
|
|
55
|
+
client.close if client.respond_to?(:close)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -5,6 +5,7 @@ module Slk
|
|
|
5
5
|
# Resolves sent-conversation destinations while retaining raw channel IDs
|
|
6
6
|
# and names in JSON. Group DMs need member lookup: their Slack name is an
|
|
7
7
|
# opaque mpdm-* slug, not a user ID that MentionReplacer can resolve.
|
|
8
|
+
# Named group DMs ("named chats") skip that and show as #name.
|
|
8
9
|
class SentChannelLabel
|
|
9
10
|
USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
|
|
10
11
|
|
|
@@ -28,6 +29,9 @@ module Slk
|
|
|
28
29
|
private
|
|
29
30
|
|
|
30
31
|
def group_label(workspace, channel_id, name, self_user_id, self_username)
|
|
32
|
+
named = Support::GroupDmName.label(name)
|
|
33
|
+
return named if named
|
|
34
|
+
|
|
31
35
|
names = group_member_names(workspace, channel_id, self_user_id)
|
|
32
36
|
return "@#{names.join(', ')}" if names.any?
|
|
33
37
|
|
|
@@ -66,19 +66,8 @@ module Slk
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def resolve_channel(workspace, name)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
fetch_and_cache_channel(workspace, name)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def fetch_and_cache_channel(workspace, name)
|
|
76
|
-
channels = @runner.conversations_api(workspace.name).list['channels'] || []
|
|
77
|
-
channel = channels.find { |c| c['name'] == name }
|
|
78
|
-
raise ConfigError, "Channel not found: ##{name}" unless channel
|
|
79
|
-
|
|
80
|
-
@cache.set_channel(workspace.name, name, channel['id'])
|
|
81
|
-
channel['id']
|
|
69
|
+
ChannelLookup.new(runner: @runner, cache_store: @cache).find_id(workspace, name) ||
|
|
70
|
+
raise(ConfigError, "Channel not found: ##{name}")
|
|
82
71
|
end
|
|
83
72
|
|
|
84
73
|
def resolve_dm(workspace, username)
|
|
@@ -14,13 +14,15 @@ module Slk
|
|
|
14
14
|
|
|
15
15
|
# Mark all unreads in a workspace, return counts
|
|
16
16
|
# @param options [Hash] :muted - include muted channels
|
|
17
|
-
# @return [Hash] { dms: count, channels: count, threads: count }
|
|
17
|
+
# @return [Hash] { dms: count, group_dms: count, channels: count, threads: count }
|
|
18
18
|
def mark_all(options: {})
|
|
19
19
|
counts = @client.counts
|
|
20
|
+
muted_ids = options[:muted] ? [] : @users.muted_channels
|
|
20
21
|
|
|
21
22
|
{
|
|
22
23
|
dms: mark_dms(counts['ims'] || []),
|
|
23
|
-
|
|
24
|
+
group_dms: mark_channels(counts['mpims'] || [], muted_ids),
|
|
25
|
+
channels: mark_channels(counts['channels'] || [], muted_ids),
|
|
24
26
|
threads: mark_threads
|
|
25
27
|
}
|
|
26
28
|
end
|
|
@@ -44,13 +46,14 @@ module Slk
|
|
|
44
46
|
count
|
|
45
47
|
end
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
# Channels and group DMs (client.counts `mpims`): a muted group DM is
|
|
50
|
+
# skipped the same way a muted channel is.
|
|
51
|
+
def mark_channels(channels, muted_ids)
|
|
49
52
|
count = 0
|
|
50
53
|
|
|
51
54
|
channels.each do |channel|
|
|
52
55
|
next unless channel['has_unreads']
|
|
53
|
-
next if
|
|
56
|
+
next if muted_ids.include?(channel['id'])
|
|
54
57
|
|
|
55
58
|
count += 1 if mark_conversation(channel['id'])
|
|
56
59
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Support
|
|
5
|
+
# Group DMs can be given a name in Slack ("named chats"). They are still
|
|
6
|
+
# mpims, but `name` holds what the user typed ("ifams review") rather
|
|
7
|
+
# than the generated mpdm-alice--bob-1 slug. conversations.info flags them
|
|
8
|
+
# with properties.has_custom_mpdm_name; search.messages sends no
|
|
9
|
+
# properties, so a name that isn't an mpdm-* slug counts as custom too.
|
|
10
|
+
module GroupDmName
|
|
11
|
+
SLUG = /\Ampdm-/
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param channel [Hash] conversation payload from Slack
|
|
16
|
+
# @return [String, nil] the custom name, or nil for anything else
|
|
17
|
+
def custom(channel)
|
|
18
|
+
return nil unless channel.is_a?(Hash) && channel['is_mpim']
|
|
19
|
+
|
|
20
|
+
name = channel['name'].to_s
|
|
21
|
+
return nil if name.empty?
|
|
22
|
+
return name if channel.dig('properties', 'has_custom_mpdm_name')
|
|
23
|
+
|
|
24
|
+
custom_name?(name) ? name : nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param name [String, nil] a group DM's Slack name
|
|
28
|
+
# @return [Boolean] true when the name was set by a person
|
|
29
|
+
def custom_name?(name)
|
|
30
|
+
!name.to_s.empty? && !name.to_s.match?(SLUG)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String, nil] "#name" for a named group DM, else nil
|
|
34
|
+
def label(name)
|
|
35
|
+
"##{name}" if custom_name?(name)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -68,6 +68,8 @@ module Slk
|
|
|
68
68
|
public
|
|
69
69
|
|
|
70
70
|
# Resolves a channel ID to a formatted label (@username or #channel)
|
|
71
|
+
# Group DMs get "#name" when named, else "@other, members", never the
|
|
72
|
+
# mpdm-* slug that other lookups may have put in the channel cache.
|
|
71
73
|
# @param workspace [Models::Workspace] The workspace to look up in
|
|
72
74
|
# @param channel_id [String] The channel ID
|
|
73
75
|
# @return [String] Formatted label like "@username" or "#channel"
|
|
@@ -97,8 +99,11 @@ module Slk
|
|
|
97
99
|
end
|
|
98
100
|
|
|
99
101
|
def resolve_channel_label(workspace, channel_id)
|
|
102
|
+
known = group_dm_label.known(workspace, channel_id)
|
|
103
|
+
return known if known
|
|
104
|
+
|
|
100
105
|
cached_name = cache_store.get_channel_name(workspace.name, channel_id)
|
|
101
|
-
return "##{cached_name}" if cached_name
|
|
106
|
+
return "##{cached_name}" if cached_name && !cached_name.match?(Support::GroupDmName::SLUG)
|
|
102
107
|
|
|
103
108
|
fetch_channel_label(workspace, channel_id)
|
|
104
109
|
end
|
|
@@ -108,16 +113,27 @@ module Slk
|
|
|
108
113
|
response = conversations.info(channel: channel_id)
|
|
109
114
|
return "##{channel_id}" unless response['ok'] && response['channel']
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
return
|
|
116
|
+
channel = response['channel']
|
|
117
|
+
return group_dm_label.label_from(workspace, channel_id, channel) if channel['is_mpim']
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
"##{name}"
|
|
119
|
+
named_channel_label(workspace, channel_id, channel['name'])
|
|
116
120
|
rescue ApiError => e
|
|
117
121
|
debug("Channel info lookup failed for #{channel_id}: #{e.message}")
|
|
118
122
|
"##{channel_id}"
|
|
119
123
|
end
|
|
120
124
|
|
|
125
|
+
def named_channel_label(workspace, channel_id, name)
|
|
126
|
+
return "##{channel_id}" unless name
|
|
127
|
+
|
|
128
|
+
cache_store.set_channel(workspace.name, name, channel_id)
|
|
129
|
+
"##{name}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# One per command run, so a group DM is looked up once for all headers.
|
|
133
|
+
def group_dm_label
|
|
134
|
+
@group_dm_label ||= Services::GroupDmLabel.new(runner: runner, on_debug: ->(msg) { debug(msg) })
|
|
135
|
+
end
|
|
136
|
+
|
|
121
137
|
def name_from_user_profile(msg)
|
|
122
138
|
return nil unless msg['user_profile']
|
|
123
139
|
|
data/lib/slk/version.rb
CHANGED