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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +21 -0
  4. data/lib/slk/api/users.rb +19 -5
  5. data/lib/slk/cli.rb +1 -0
  6. data/lib/slk/commands/activity.rb +6 -0
  7. data/lib/slk/commands/catchup.rb +29 -11
  8. data/lib/slk/commands/debug.rb +15 -4
  9. data/lib/slk/commands/help.rb +1 -0
  10. data/lib/slk/commands/huddles.rb +94 -0
  11. data/lib/slk/commands/messages.rb +20 -3
  12. data/lib/slk/commands/thread.rb +1 -2
  13. data/lib/slk/commands/unread.rb +34 -19
  14. data/lib/slk/commands/who.rb +22 -2
  15. data/lib/slk/formatters/huddle_formatter.rb +54 -0
  16. data/lib/slk/formatters/json_message_formatter.rb +1 -1
  17. data/lib/slk/formatters/mention_replacer.rb +9 -3
  18. data/lib/slk/formatters/profile_formatter.rb +9 -0
  19. data/lib/slk/formatters/profile_rows.rb +14 -1
  20. data/lib/slk/formatters/search_formatter.rb +4 -0
  21. data/lib/slk/models/huddle.rb +17 -0
  22. data/lib/slk/models/profile.rb +16 -1
  23. data/lib/slk/models/search_result.rb +3 -0
  24. data/lib/slk/services/activity_enricher.rb +33 -13
  25. data/lib/slk/services/api_client.rb +58 -2
  26. data/lib/slk/services/cache_store.rb +13 -2
  27. data/lib/slk/services/channel_lookup.rb +78 -0
  28. data/lib/slk/services/group_dm_label.rb +83 -0
  29. data/lib/slk/services/huddle_channel_label.rb +62 -0
  30. data/lib/slk/services/huddle_fetch.rb +90 -0
  31. data/lib/slk/services/huddle_scanner.rb +101 -0
  32. data/lib/slk/services/huddle_state.rb +41 -0
  33. data/lib/slk/services/profile_builder.rb +1 -0
  34. data/lib/slk/services/profile_resolver.rb +1 -1
  35. data/lib/slk/services/sent_channel_label.rb +4 -0
  36. data/lib/slk/services/target_resolver.rb +2 -13
  37. data/lib/slk/services/unread_marker.rb +8 -5
  38. data/lib/slk/services/workspace_fanout.rb +28 -0
  39. data/lib/slk/support/group_dm_name.rb +39 -0
  40. data/lib/slk/support/user_resolver.rb +21 -5
  41. data/lib/slk/version.rb +1 -1
  42. data/lib/slk.rb +13 -0
  43. 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
- name = response['channel']['name']
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
@@ -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, :sections, :custom_fields, :resolved_users
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?
@@ -106,6 +106,9 @@ module Slk
106
106
  end
107
107
 
108
108
  def display_channel
109
+ named = channel_type == 'mpim' && Support::GroupDmName.label(channel_name)
110
+ return named if named
111
+
109
112
  if dm?
110
113
  "@#{channel_name}"
111
114
  else
@@ -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 name (with API fallback)
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
- name = fetch_channel_name(workspace, channel_id)
49
- return channel_id unless name
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 ? "##{name}" : name
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 fetch_channel_name(workspace, channel_id)
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
- return nil unless response['ok'] && response['channel']
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 = response['channel']['name']
112
- @cache.set_channel(workspace.name, name, channel_id)
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
- @call_count += 1
161
- @on_request&.call(method, @call_count)
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.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
@@ -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