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.
@@ -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.key(channel_id)
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
- @channel_cache[workspace_name] ||= {}
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 'group DM' if channel['is_mpim']
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
- huddles.sort_by { |huddle| sort_key(huddle) }
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) }.sort_by { |person| [person.name.downcase, person.id] }
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
- cached = @cache.get_channel_id(workspace.name, name)
70
- return cached if cached
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
- channels: mark_channels(counts['channels'] || [], muted: options[:muted]),
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
- def mark_channels(channels, muted: false)
48
- muted_ids = muted ? [] : @users.muted_channels
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 !muted && muted_ids.include?(channel['id'])
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
- name = response['channel']['name']
112
- return "##{channel_id}" unless name
116
+ channel = response['channel']
117
+ return group_dm_label.label_from(workspace, channel_id, channel) if channel['is_mpim']
113
118
 
114
- cache_store.set_channel(workspace.name, name, channel_id)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.14.0'
4
+ VERSION = '0.16.0'
5
5
  end