slk 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '080abcf262e2c943cb01e3fe6fc5619ee0921d93d4418deeb5f42d6fc4154a92'
4
- data.tar.gz: 762989ed0de46ef13372d550a80517bf2c3c7ce73eaf916c8d78fafd5d6e6f57
3
+ metadata.gz: 5edea2fd65213e0875f06f53729966f8658ae880bd38321bd9a9d8980200c07a
4
+ data.tar.gz: 7a532e0e1991807962aae21099ad2e9c2c98b03a364dc3e6db90ebf7f2fdabf6
5
5
  SHA512:
6
- metadata.gz: bc5bc2aafe45a6ab8a20fac51086e426d2586012be06b82d87fa3560597386b6d0a504a1489b8b75cf9af5c00627dd7b10a2714af8479fee37202408963e10b8
7
- data.tar.gz: a835b18a0a8155c73d2618fa484393dcfaf9b11fbbd0583530209b810e0cadd09ddf45fa5618c54a75f9581dfd0a165d35257cd6faa0add128c08a51d03b0047
6
+ metadata.gz: 256c73b33433f9488bfcd1333c14f57e953e80fea54a07500239933cdf2129e682beb5d18cc3ab6f730d77570fd103ea77ab2b2ad7f2f8386d80924e6240f5e1
7
+ data.tar.gz: 388ffeef9e34949a549fb4cc7bcd8560e7132ad9ebde68b189c7dccfac5b749e3e21445c41fea2f614e5a3f1f179101b45b15727a219a3ab289d916aa0cb4f8a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.16.0] - 2026-10-29
11
+
12
+ ### Added
13
+
14
+ - `slk debug huddle <call id|user>` (hidden command) prints the raw `huddles.get` response. A user is resolved to the call in their profile's `huddle_state_call_id`.
15
+
16
+ ### Fixed
17
+
18
+ - `slk huddles` and `slk who` show the huddle's channel again. Slack stopped sending `huddle_state_channel_id` on profiles, so every huddle read "channel unknown". The call id is now looked up with `huddles.get`, which also adds Slack Connect guests missing from `users.list`. Huddles in conversations you can't see still show "channel unknown". Room and guest-name lookups run on four connections per workspace, about 3× faster than one at a time.
19
+
10
20
  ## [0.15.0] - 2026-10-13
11
21
 
12
22
  ### Added
data/README.md CHANGED
@@ -232,9 +232,11 @@ slk huddles --json # Call id, channel, and participants
232
232
  ```
233
233
 
234
234
  Slack has no huddle list. `slk huddles` reads `huddle_state` from `users.list`
235
- and groups people who share a call id. A huddle with no channel is one Slack
236
- did not attach to a conversation. People outside the workspace, including some
237
- Slack Connect guests, are not in the roster and will not appear. The roster is
235
+ and groups people who share a call id. Profiles no longer say which channel a
236
+ huddle is in, so each call id is looked up with `huddles.get` (undocumented),
237
+ which also lists Slack Connect guests missing from the roster. That only works
238
+ for conversations you can see; any other huddle shows "channel unknown" and
239
+ only its roster members. The roster is
238
240
  fetched live and is not cached. `--all` scans every workspace at the same time.
239
241
  One connection can only read one roster, so each workspace gets its own.
240
242
  People who are not in a huddle are dropped as each page arrives. `slk who`
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Api
5
+ # Wrapper for Slack's undocumented huddles.* endpoints (the web client's).
6
+ class Huddles
7
+ def initialize(api_client, workspace)
8
+ @api = api_client
9
+ @workspace = workspace
10
+ end
11
+
12
+ # The room for one call id: channels, live participants, has_ended.
13
+ # Raises not_authorized for a huddle in a conversation the token
14
+ # cannot see.
15
+ def get(huddle_id)
16
+ info(huddle_id)['huddle'] || {}
17
+ end
18
+
19
+ # The whole response, for `slk debug huddle`.
20
+ def info(huddle_id)
21
+ @api.post(@workspace, 'huddles.get', { huddle_id: huddle_id })
22
+ end
23
+ end
24
+ end
25
+ end
@@ -25,12 +25,13 @@ module Slk
25
25
  in ['profile'] then dump_profile(nil)
26
26
  in ['team'] then dump_team
27
27
  in ['schema'] then dump_schema
28
- in ['thread', url] then dump_thread(url)
29
- in ['channel', target] then dump_channel(target)
28
+ in [action, target] if TARGET_ACTIONS.key?(action) then send(TARGET_ACTIONS[action], target)
30
29
  else unknown_action
31
30
  end
32
31
  end
33
32
 
33
+ TARGET_ACTIONS = { 'thread' => :dump_thread, 'channel' => :dump_channel, 'huddle' => :dump_huddle }.freeze
34
+
34
35
  private
35
36
 
36
37
  def dump_profile(user_input)
@@ -79,6 +80,26 @@ module Slk
79
80
  0
80
81
  end
81
82
 
83
+ # Raw huddles.get for a call id (R...), or for the call a user is in
84
+ # now. Profiles carry only the call id; this is where the channel and
85
+ # the full participant list (Slack Connect guests too) come from.
86
+ def dump_huddle(target)
87
+ workspace = runner.workspace(@options[:workspace])
88
+ call_id = huddle_call_id(workspace, target)
89
+ output.puts(JSON.pretty_generate(Api::Huddles.new(api_client, workspace).info(call_id)))
90
+ 0
91
+ end
92
+
93
+ def huddle_call_id(workspace, target)
94
+ return target if target.match?(/\AR[A-Z0-9]+\z/)
95
+
96
+ user_id = resolve_user_id(workspace, target)
97
+ call_id = runner.users_api(workspace.name).info(user_id).dig('user', 'profile', 'huddle_state_call_id')
98
+ raise ApiError, "#{target} is not in a huddle" if call_id.to_s.empty?
99
+
100
+ call_id
101
+ end
102
+
82
103
  def resolve_target(target)
83
104
  Services::TargetResolver.new(runner: runner, cache_store: cache_store)
84
105
  .resolve(target, default_workspace: runner.workspace(@options[:workspace]))
@@ -128,7 +149,7 @@ module Slk
128
149
 
129
150
  def unknown_action
130
151
  error("Unknown debug action: #{positional_args.first.inspect}")
131
- error('Valid actions: profile [user], team, schema, thread <url>, channel <id|url>')
152
+ error('Valid actions: profile [user], team, schema, thread <url>, channel <id|url>, huddle <call|user>')
132
153
  1
133
154
  end
134
155
 
@@ -146,6 +167,7 @@ module Slk
146
167
  schema Dump team.profile.get
147
168
  thread <url> Dump thread parent + subscriptions.thread.get
148
169
  channel <id|url> Dump conversations.info (also #name)
170
+ huddle <call|user> Dump huddles.get for a call id (R...) or a user's current call
149
171
 
150
172
  USER
151
173
  (none) | me Self
@@ -25,7 +25,8 @@ module Slk
25
25
  help.note('Slack has no huddle list. This reads huddle_state from users.list')
26
26
  help.note('and groups people who share a call id. The roster is fetched live.')
27
27
  help.note('Primary workspace only. --all scans every workspace at the same time.')
28
- help.note('A Slack Connect guest not in the roster is omitted.')
28
+ help.note('Channels and Slack Connect guests come from huddles.get, so only')
29
+ help.note('huddles in conversations you can see get them.')
29
30
  add_options_section(help)
30
31
  add_examples_section(help)
31
32
  help.render
@@ -89,12 +89,13 @@ module Slk
89
89
  # Channel ids are useless on a card. A failed lookup still leaves the
90
90
  # state, which is the part Slack always sends.
91
91
  def attach_huddle_channel(workspace, profile)
92
- return profile if profile.huddle_channel_id.to_s.empty?
92
+ channel_id = Services::HuddleRoom.new(api_client: api_client, workspace: workspace).channel_for(profile)
93
+ return profile if channel_id.to_s.empty?
93
94
 
94
- label = huddle_channels.label(workspace, profile.huddle_channel_id)
95
+ label = huddle_channels.label(workspace, channel_id)
95
96
  return profile unless label
96
97
 
97
- Models::Profile.new(**profile.to_h, huddle_channel: label)
98
+ Models::Profile.new(**profile.to_h, huddle_channel_id: channel_id, huddle_channel: label)
98
99
  end
99
100
 
100
101
  def huddle_channels
@@ -45,7 +45,7 @@ module Slk
45
45
  child.on_response = @on_response
46
46
  child.on_request_body = @on_request_body
47
47
  child.on_response_body = @on_response_body
48
- child.share_counter(self)
48
+ child.share_counter(@counter || self)
49
49
  child
50
50
  end
51
51
 
@@ -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
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.15.0'
4
+ VERSION = '0.16.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -123,6 +123,9 @@ module Slk
123
123
  autoload :HuddleScanner, 'slk/services/huddle_scanner'
124
124
  autoload :HuddleFetch, 'slk/services/huddle_fetch'
125
125
  autoload :HuddleState, 'slk/services/huddle_state'
126
+ autoload :HuddleRoom, 'slk/services/huddle_room'
127
+ autoload :HuddleRoomEnricher, 'slk/services/huddle_room_enricher'
128
+ autoload :PooledFanout, 'slk/services/pooled_fanout'
126
129
  autoload :WorkspaceFanout, 'slk/services/workspace_fanout'
127
130
  autoload :HuddleChannelLabel, 'slk/services/huddle_channel_label'
128
131
  autoload :GroupDmLabel, 'slk/services/group_dm_label'
@@ -199,6 +202,7 @@ module Slk
199
202
  autoload :Saved, 'slk/api/saved'
200
203
  autoload :CustomStatus, 'slk/api/custom_status'
201
204
  autoload :Team, 'slk/api/team'
205
+ autoload :Huddles, 'slk/api/huddles'
202
206
  end
203
207
 
204
208
  # Utility classes for paths, parsing, and helpers
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -33,6 +33,7 @@ files:
33
33
  - lib/slk/api/custom_status.rb
34
34
  - lib/slk/api/dnd.rb
35
35
  - lib/slk/api/emoji.rb
36
+ - lib/slk/api/huddles.rb
36
37
  - lib/slk/api/saved.rb
37
38
  - lib/slk/api/search.rb
38
39
  - lib/slk/api/team.rb
@@ -120,10 +121,13 @@ files:
120
121
  - lib/slk/services/group_dm_label.rb
121
122
  - lib/slk/services/huddle_channel_label.rb
122
123
  - lib/slk/services/huddle_fetch.rb
124
+ - lib/slk/services/huddle_room.rb
125
+ - lib/slk/services/huddle_room_enricher.rb
123
126
  - lib/slk/services/huddle_scanner.rb
124
127
  - lib/slk/services/huddle_state.rb
125
128
  - lib/slk/services/message_resolver.rb
126
129
  - lib/slk/services/meta_cache.rb
130
+ - lib/slk/services/pooled_fanout.rb
127
131
  - lib/slk/services/preset_store.rb
128
132
  - lib/slk/services/profile_builder.rb
129
133
  - lib/slk/services/profile_resolver.rb