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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbd57363408aa79edfa6cbbe3e875034d2fbcb9e3c2a7ec8ea1e9acea30c9167
4
- data.tar.gz: bf9ab8b668ad9d04191ddb9b63a1c502d3652059c3b5bbc16113dff1d94eb8bc
3
+ metadata.gz: '080abcf262e2c943cb01e3fe6fc5619ee0921d93d4418deeb5f42d6fc4154a92'
4
+ data.tar.gz: 762989ed0de46ef13372d550a80517bf2c3c7ce73eaf916c8d78fafd5d6e6f57
5
5
  SHA512:
6
- metadata.gz: 71d05dc4a9f5b46c49e5a14cad5c5e9b79042ece234142bb16f53dde80da67a5beafd1a2af0f30ef33b2bb8ef5381d64393979e640c996810bb536005e9a8180
7
- data.tar.gz: 49956b5f79adb5e03f4bac033109626a315f9a1326904e321d4dcf2e17d189d7ac500bb820e2dab52199243d916c204b6ecef94b0c445c8287d591bdd1bfaa20
6
+ metadata.gz: bc5bc2aafe45a6ab8a20fac51086e426d2586012be06b82d87fa3560597386b6d0a504a1489b8b75cf9af5c00627dd7b10a2714af8479fee37202408963e10b8
7
+ data.tar.gz: a835b18a0a8155c73d2618fa484393dcfaf9b11fbbd0583530209b810e0cadd09ddf45fa5618c54a75f9581dfd0a165d35257cd6faa0add128c08a51d03b0047
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-10-13
11
+
12
+ ### Added
13
+
14
+ - Named group DMs ("named chats") show under their name, such as `#ifams review`, in `slk search`, `slk sent` (header and JSON `channel_label`), and `slk huddles`. Before, search showed `<@ifams review>`, sent listed the members, and huddles showed `group DM`. Slack reports these as `is_mpim` with `properties.has_custom_mpdm_name`. Search results carry no `properties`, so a group DM whose name is not an `mpdm-*` slug also counts as named. These commands don't cache a named group DM by name, because a chat can be renamed or share a name with a real channel.
15
+ - `slk debug channel <id|url|#name>` (hidden command) prints the raw `conversations.info` response.
16
+
17
+ ### Fixed
18
+
19
+ - `slk unread` and `slk catchup` now show group DMs. Slack lists them under `mpims` in `client.counts`, and both commands only read `ims` and `channels`, so a group DM's unreads never appeared. A named group DM is headed by its name (`#ifams review`); any other by the people in it besides you (`@Ada Lovelace, Grace Hopper`). A muted group DM is skipped like a muted channel unless `--muted` is passed. `slk unread --json` adds a `group_dms` array of `{id, mentions, label}`.
20
+ - `slk unread clear` and `slk catchup --batch` now mark group DMs read, and their summaries count them ("Cleared 2 DMs, 1 group DMs, 5 channels and 3 threads"). `unread clear` also reports DMs, which it marked but did not count.
21
+ - Thread headers in `slk unread` and `slk catchup` show a group DM the same way: `#ifams review` for a named one, `@Ada Lovelace, Grace Hopper` for others. Before, they showed the raw `#mpdm-ada--eric--grace-1` slug, and cached it. A slug that other commands already cached is ignored.
22
+ - `slk activity` shows a group DM as `#ifams review` when it is named and `@Ada Lovelace, Grace Hopper` otherwise, in text and in `--json` `channel_name`. Before, it showed and cached the raw `#mpdm-…` slug. Legacy `G…` conversations now show their real name too; they read "Group DM" only when Slack won't say what they are.
23
+ - `slk messages --json` and `slk thread --json` fill `channel_name` from one `conversations.info` lookup when the name isn't cached: `ifams review` for a named group DM, `@Ada Lovelace, Grace Hopper` for others, never the `mpdm-…` slug. DMs and `--no-names` still use the cache only. `slk thread subscribe` and `unsubscribe` name the conversation the same way.
24
+ - `slk messages "#name"`, `slk unread clear "#name"`, and `slk debug channel "#name"` now find named group DMs (`slk messages "#ifams review"`) and any channel you belong to. Lookup pages through `users.conversations` (channels, private channels, and group DMs; archived excluded) and matches names without regard to case. Before, it read one page of `conversations.list`, which Slack caps at 100 channels and which leaves out group DMs. A channel wins over a named group DM with the same name. Public channels you have not joined still fall back to that one page.
25
+ - The channel cache no longer stores group DM `mpdm-*` slugs. These are Slack's internal names, not names anyone types. Slugs cached by older versions are ignored when names are read, and dropped the next time the cache is written. `slk activity` and channel mentions in messages also stop caching named group DMs by name, the same as `slk huddles`.
26
+ - Writing to the channel cache before reading from it no longer replaces the file with just the new entry.
27
+ - `slk unread` no longer prints "No unread messages" after listing unread DMs when no channel is unread.
28
+ - `slk unread clear "#general"` printed "Marked ##general"; it now prints one `#`.
29
+
30
+ ## [0.14.0] - 2026-10-09
31
+
32
+ ### Added
33
+
34
+ - `slk huddles` lists active huddles. Slack has no huddle list, so it reads `huddle_state` from `users.list` and groups people who share a `huddle_state_call_id`. Channel names are filled in when Slack sends a channel id. Primary workspace by default; `--all` scans every workspace at the same time, each on its own connection, and drops people who are not in a huddle as each page arrives. `--json` prints the call id, channel, and participants.
35
+ - Slack API responses are requested gzip-compressed. A `users.list` page is the bulk of a huddle scan, and compression is the difference between a large JSON document and a fraction of that on the wire.
36
+ - `slk who` shows huddle state. The profile cache is an hour old, so the state is read live from `users.info` and the cached copy is ignored. `default_unset` is omitted. A channel name is added when Slack sends a channel id.
37
+
10
38
  ## [0.13.0] - 2026-10-09
11
39
 
12
40
  ### Added
data/README.md CHANGED
@@ -184,8 +184,11 @@ Use `--show-messages` (or `-m`) to preview the actual message content for each a
184
184
  slk unread # Show unread counts
185
185
  slk unread clear # Mark all as read
186
186
  slk unread clear general # Mark channel as read
187
+ slk unread clear "#ifams review" # Named group DMs work by name too
187
188
  ```
188
189
 
190
+ Unread DMs, group DMs, channels, and threads are all listed. A named group DM shows under its name (`#ifams review`); others show the people in them.
191
+
189
192
  ### Catchup (Interactive Triage)
190
193
 
191
194
  ```bash
@@ -220,6 +223,24 @@ slk cache populate # Pre-populate user cache
220
223
  slk cache clear # Clear all caches
221
224
  ```
222
225
 
226
+ ### Huddles
227
+
228
+ ```bash
229
+ slk huddles # Active huddles on the primary workspace
230
+ slk huddles --all # Every workspace
231
+ slk huddles --json # Call id, channel, and participants
232
+ ```
233
+
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
238
+ fetched live and is not cached. `--all` scans every workspace at the same time.
239
+ One connection can only read one roster, so each workspace gets its own.
240
+ People who are not in a huddle are dropped as each page arrives. `slk who`
241
+ shows the same state on the profile card, read live so the hour-long profile
242
+ cache cannot leave someone in a huddle they have already left.
243
+
223
244
  ### Deactivations
224
245
 
225
246
  ```bash
data/lib/slk/api/users.rb CHANGED
@@ -68,13 +68,25 @@ module Slk
68
68
  # total after each page, for progress and debug output.
69
69
  def list_all(limit: 1000, &progress)
70
70
  members = []
71
+ list_each(limit: limit) do |page, total|
72
+ members.concat(page)
73
+ progress&.call(total)
74
+ end
75
+ members
76
+ end
77
+
78
+ # Yields each page and the running total, then drops the page. A caller
79
+ # that only wants a few members should not have to retain the roster.
80
+ def list_each(limit: 1000)
71
81
  cursor = nil
82
+ total = 0
72
83
  loop do
73
84
  response = list(cursor: cursor, limit: limit)
74
- members.concat(response['members'] || [])
75
- progress&.call(members.size)
85
+ page = response['members'] || []
86
+ total += page.size
87
+ yield page, total
76
88
  cursor = next_cursor(response, cursor)
77
- break members unless cursor
89
+ break unless cursor
78
90
  end
79
91
  end
80
92
 
@@ -131,9 +143,11 @@ module Slk
131
143
 
132
144
  public
133
145
 
134
- def conversations(cursor: nil, limit: 1000)
135
- params = { limit: limit, types: 'public_channel,private_channel,mpim,im' }
146
+ def conversations(cursor: nil, limit: 1000, types: 'public_channel,private_channel,mpim,im',
147
+ exclude_archived: false)
148
+ params = { limit: limit, types: types }
136
149
  params[:cursor] = cursor if cursor
150
+ params[:exclude_archived] = true if exclude_archived
137
151
  @api.post_form(@workspace, 'users.conversations', params)
138
152
  end
139
153
  end
data/lib/slk/cli.rb CHANGED
@@ -24,6 +24,7 @@ module Slk
24
24
  'help' => Commands::Help,
25
25
  'debug' => Commands::Debug,
26
26
  'who' => Commands::Who,
27
+ 'huddles' => Commands::Huddles,
27
28
  'org' => Commands::Org,
28
29
  'deactivations' => Commands::Deactivations
29
30
  }.freeze
@@ -109,10 +109,16 @@ module Slk
109
109
  Services::ActivityEnricher.new(
110
110
  cache_store: cache_store,
111
111
  conversations_api: runner.conversations_api(workspace.name),
112
+ group_dm_label: group_dm_label,
112
113
  on_debug: ->(msg) { debug(msg) }
113
114
  )
114
115
  end
115
116
 
117
+ # One per run, so each group DM is looked up once however often it appears.
118
+ def group_dm_label
119
+ @group_dm_label ||= Services::GroupDmLabel.new(runner: runner, on_debug: ->(msg) { debug(msg) })
120
+ end
121
+
116
122
  def formatter(workspace)
117
123
  Formatters::ActivityFormatter.new(
118
124
  output: output,
@@ -106,7 +106,7 @@ module Slk
106
106
  def batch_mark_workspace(workspace)
107
107
  marker = build_unread_marker(workspace)
108
108
  counts = marker.mark_all(options: { muted: @options[:muted] })
109
- success("Marked #{counts[:dms]} DMs, #{counts[:channels]} channels, " \
109
+ success("Marked #{counts[:dms]} DMs, #{counts[:group_dms]} group DMs, #{counts[:channels]} channels, " \
110
110
  "and #{counts[:threads]} threads as read on #{workspace.name}")
111
111
  end
112
112
 
@@ -145,18 +145,25 @@ module Slk
145
145
 
146
146
  def gather_unread_items(workspace)
147
147
  counts = runner.client_api(workspace.name).counts
148
- ims = filter_unread_ims(counts['ims'] || [])
149
- channels = filter_unread_channels(workspace, counts['channels'] || [])
150
- threads_response = fetch_unread_threads(workspace)
148
+ muted_ids = muted_channel_ids(workspace)
149
+ build_items_result(
150
+ filter_unread_ims(counts['ims'] || []),
151
+ filter_unread_channels(counts['mpims'] || [], muted_ids),
152
+ filter_unread_channels(counts['channels'] || [], muted_ids),
153
+ fetch_unread_threads(workspace)
154
+ )
155
+ end
151
156
 
152
- build_items_result(ims, channels, threads_response)
157
+ def muted_channel_ids(workspace)
158
+ @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
153
159
  end
154
160
 
155
- def build_items_result(ims, channels, threads_response)
161
+ def build_items_result(ims, group_dms, channels, threads_response)
162
+ conversations = ims.size + group_dms.size + channels.size
156
163
  {
157
- ims: ims, channels: channels, threads_response: threads_response,
158
- total: ims.size + channels.size + (threads_response ? 1 : 0),
159
- empty: ims.empty? && channels.empty? && !threads_response
164
+ ims: ims, group_dms: group_dms, channels: channels, threads_response: threads_response,
165
+ total: conversations + (threads_response ? 1 : 0),
166
+ empty: conversations.zero? && !threads_response
160
167
  }
161
168
  end
162
169
 
@@ -164,8 +171,8 @@ module Slk
164
171
  ims.select { |i| i['has_unreads'] }
165
172
  end
166
173
 
167
- def filter_unread_channels(workspace, channels)
168
- muted_ids = @options[:muted] ? [] : runner.users_api(workspace.name).muted_channels
174
+ # Channels and group DMs; a muted group DM is skipped like a muted channel.
175
+ def filter_unread_channels(channels, muted_ids)
169
176
  channels
170
177
  .select { |c| c['has_unreads'] || (c['mention_count'] || 0).positive? }
171
178
  .reject { |c| muted_ids.include?(c['id']) }
@@ -180,6 +187,7 @@ module Slk
180
187
  index = { current: 0, total: items[:total] }
181
188
 
182
189
  return :quit if process_dms(workspace, items[:ims], index)
190
+ return :quit if process_group_dms(workspace, items[:group_dms], index)
183
191
  return :quit if items[:threads_response] && process_threads_item(workspace, items[:threads_response], index)
184
192
  return :quit if process_channels(workspace, items[:channels], index)
185
193
 
@@ -196,6 +204,16 @@ module Slk
196
204
  false
197
205
  end
198
206
 
207
+ def process_group_dms(workspace, group_dms, index)
208
+ group_dms.each do |group_dm|
209
+ label = group_dm_label.label(workspace, group_dm['id'])
210
+ return true if process_conversation(workspace, group_dm, index[:current], index[:total], label) == :quit
211
+
212
+ index[:current] += 1
213
+ end
214
+ false
215
+ end
216
+
199
217
  def process_channels(workspace, channels, index)
200
218
  channels.each do |channel|
201
219
  return true if process_channel(workspace, channel, index[:current], index[:total]) == :quit
@@ -26,6 +26,7 @@ module Slk
26
26
  in ['team'] then dump_team
27
27
  in ['schema'] then dump_schema
28
28
  in ['thread', url] then dump_thread(url)
29
+ in ['channel', target] then dump_channel(target)
29
30
  else unknown_action
30
31
  end
31
32
  end
@@ -61,7 +62,7 @@ module Slk
61
62
  # carry `subscribed`/`last_read`?) and subscriptions.thread.get. Each call
62
63
  # is captured separately so one failing does not hide the other.
63
64
  def dump_thread(url)
64
- resolved = resolve_thread(url)
65
+ resolved = resolve_target(url)
65
66
  ts = resolved.thread_ts || resolved.msg_ts
66
67
  raise UsageError, 'URL must point to a specific message' unless ts
67
68
 
@@ -69,9 +70,18 @@ module Slk
69
70
  0
70
71
  end
71
72
 
72
- def resolve_thread(url)
73
+ # Raw conversations.info: shows the flags labels key off (is_mpim,
74
+ # properties.has_custom_mpdm_name for named group DMs, is_ext_shared).
75
+ def dump_channel(target)
76
+ resolved = resolve_target(target)
77
+ info = runner.conversations_api(resolved.workspace.name).info(channel: resolved.channel_id)
78
+ output.puts(JSON.pretty_generate(info))
79
+ 0
80
+ end
81
+
82
+ def resolve_target(target)
73
83
  Services::TargetResolver.new(runner: runner, cache_store: cache_store)
74
- .resolve(url, default_workspace: runner.workspace(@options[:workspace]))
84
+ .resolve(target, default_workspace: runner.workspace(@options[:workspace]))
75
85
  end
76
86
 
77
87
  def thread_calls(workspace_name, channel, thread_ts)
@@ -118,7 +128,7 @@ module Slk
118
128
 
119
129
  def unknown_action
120
130
  error("Unknown debug action: #{positional_args.first.inspect}")
121
- error('Valid actions: profile [user], team, schema, thread <url>')
131
+ error('Valid actions: profile [user], team, schema, thread <url>, channel <id|url>')
122
132
  1
123
133
  end
124
134
 
@@ -135,6 +145,7 @@ module Slk
135
145
  team Dump team.info
136
146
  schema Dump team.profile.get
137
147
  thread <url> Dump thread parent + subscriptions.thread.get
148
+ channel <id|url> Dump conversations.info (also #name)
138
149
 
139
150
  USER
140
151
  (none) | me Self
@@ -18,6 +18,7 @@ module Slk
18
18
  ['activity', 'Show activity feed (reactions, mentions, threads)'],
19
19
  ['later', 'Show saved "Later" items'],
20
20
  ['who', 'Show a user profile'],
21
+ ['huddles', 'List active huddles'],
21
22
  ['deactivations', 'Show who left the workspace, and when'],
22
23
  ['preset', 'Manage and apply status presets'],
23
24
  ['workspaces', 'Manage Slack workspaces'],
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../support/help_formatter'
4
+
5
+ module Slk
6
+ module Commands
7
+ # List active huddles by grouping users.list profiles that share a call id.
8
+ class Huddles < Base
9
+ def execute
10
+ result = validate_options
11
+ return result if result
12
+ return unexpected_argument if positional_args.any?
13
+
14
+ emit(Services::HuddleFetch.new(runner: runner, output: output).call(target_workspaces))
15
+ 0
16
+ rescue ApiError => e
17
+ error("API error: #{e.message}")
18
+ end
19
+
20
+ protected
21
+
22
+ def help_text
23
+ help = Support::HelpFormatter.new('slk huddles [options]')
24
+ help.description('List active huddles.')
25
+ help.note('Slack has no huddle list. This reads huddle_state from users.list')
26
+ help.note('and groups people who share a call id. The roster is fetched live.')
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.')
29
+ add_options_section(help)
30
+ add_examples_section(help)
31
+ help.render
32
+ end
33
+
34
+ private
35
+
36
+ def add_options_section(help)
37
+ help.section('OPTIONS') do |section|
38
+ section.option('-w, --workspace NAME', 'Limit to one workspace')
39
+ section.option('--all', 'Scan every workspace')
40
+ section.option('--json', 'Call id, channel, and participants')
41
+ end
42
+ end
43
+
44
+ def add_examples_section(help)
45
+ help.section('EXAMPLES') do |section|
46
+ section.example('slk huddles', 'Active huddles on the primary workspace')
47
+ section.example('slk huddles --all', 'Every configured workspace')
48
+ section.example('slk huddles --json', 'Machine-readable')
49
+ end
50
+ end
51
+
52
+ def unexpected_argument
53
+ error("Unexpected argument: #{positional_args.first}.")
54
+ 1
55
+ end
56
+
57
+ def emit(reports)
58
+ return output_json(json_documents(reports)) if @options[:json]
59
+ return if @options[:quiet]
60
+
61
+ Formatters::HuddleFormatter.new(output).render(reports, scope: single_workspace_scope(reports))
62
+ end
63
+
64
+ # A primary-only scan is easy to read as "nobody, anywhere" when other
65
+ # workspaces are configured. Name the one that was actually checked.
66
+ def single_workspace_scope(reports)
67
+ return nil unless reports.one?
68
+ return nil if runner.all_workspaces.size <= 1
69
+
70
+ reports.first.workspace
71
+ end
72
+
73
+ # One workspace prints its document alone; several are keyed by name,
74
+ # matching `slk unread --json`, so the output pipes straight into jq.
75
+ def json_documents(reports)
76
+ documents = reports.to_h { |report| [report.workspace, document(report)] }
77
+ reports.one? ? documents.values.first : documents
78
+ end
79
+
80
+ def document(report)
81
+ { huddles: report.huddles.map { |huddle| huddle_json(huddle) } }
82
+ end
83
+
84
+ def huddle_json(huddle)
85
+ {
86
+ call_id: huddle.call_id,
87
+ channel_id: huddle.channel_id,
88
+ channel: huddle.channel,
89
+ participants: huddle.participants.map { |person| { id: person.id, name: person.name } }
90
+ }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -10,6 +10,7 @@ module Slk
10
10
  class Messages < Base
11
11
  include Support::InlineImages
12
12
  include Support::AttachmentFetching
13
+ include Support::UserResolver
13
14
 
14
15
  # rubocop:disable Metrics/MethodLength
15
16
  def execute
@@ -149,7 +150,7 @@ module Slk
149
150
  end
150
151
 
151
152
  def output_json_messages(messages, workspace, channel_id)
152
- format_options = json_format_options(channel_id)
153
+ format_options = json_format_options(workspace, channel_id)
153
154
 
154
155
  json_messages = messages.map do |message|
155
156
  format_message_as_json(message, workspace, channel_id, format_options)
@@ -158,8 +159,24 @@ module Slk
158
159
  output_json(json_messages)
159
160
  end
160
161
 
161
- def json_format_options(channel_id)
162
- { no_names: @options[:no_names], reaction_timestamps: @options[:reaction_timestamps], channel_id: channel_id }
162
+ def json_format_options(workspace, channel_id)
163
+ { no_names: @options[:no_names], reaction_timestamps: @options[:reaction_timestamps], channel_id: channel_id,
164
+ channel_name: json_channel_name(workspace, channel_id) }
165
+ end
166
+
167
+ # One lookup per run: a group DM gets its name or "@other, members"
168
+ # (not its mpdm-* slug). DMs and --no-names keep the cache-only name.
169
+ def json_channel_name(workspace, channel_id)
170
+ return nil if @options[:no_names] || channel_id.start_with?('D')
171
+
172
+ conversation_label(workspace, channel_id)&.delete_prefix('#')
173
+ end
174
+
175
+ # "#name", "@other, members" for a group DM, or "@user" for a DM;
176
+ # nil when Slack won't say.
177
+ def conversation_label(workspace, channel_id)
178
+ label = resolve_conversation_label(workspace, channel_id)
179
+ label unless label == "##{channel_id}"
163
180
  end
164
181
 
165
182
  def format_message_as_json(message, workspace, channel_id, format_options)
@@ -130,8 +130,7 @@ module Slk
130
130
  end
131
131
 
132
132
  def channel_label(workspace, channel_id)
133
- name = cache_store.get_channel_name(workspace.name, channel_id)
134
- name ? "##{name}" : channel_id
133
+ conversation_label(workspace, channel_id) || channel_id
135
134
  end
136
135
 
137
136
  def subscription_error(action, err)
@@ -133,6 +133,7 @@ module Slk
133
133
 
134
134
  {
135
135
  unread_ims: filter_unread_ims(counts['ims'] || []),
136
+ unread_group_dms: filter_unread_channels(counts['mpims'] || [], muted_ids),
136
137
  unread_channels: filter_unread_channels(counts['channels'] || [], muted_ids)
137
138
  }
138
139
  end
@@ -154,10 +155,16 @@ module Slk
154
155
  def unread_json(workspace, data)
155
156
  {
156
157
  channels: data[:unread_channels].map { |c| format_channel_json(workspace, c) },
157
- dms: data[:unread_ims].map { |i| format_dm_json(workspace, i) }
158
+ dms: data[:unread_ims].map { |i| format_dm_json(workspace, i) },
159
+ group_dms: data[:unread_group_dms].map { |g| format_group_dm_json(workspace, g) }
158
160
  }
159
161
  end
160
162
 
163
+ def format_group_dm_json(workspace, group_dm)
164
+ { id: group_dm['id'], mentions: group_dm['mention_count'],
165
+ label: group_dm_label.label(workspace, group_dm['id']) }
166
+ end
167
+
161
168
  def format_channel_json(workspace, channel)
162
169
  channel_hash = { id: channel['id'], mentions: channel['mention_count'] }
163
170
  channel_name = cache_store.get_channel_name(workspace.name, channel['id'])
@@ -180,23 +187,37 @@ module Slk
180
187
  formatter = runner.message_formatter
181
188
 
182
189
  display_unread_dms(workspace, data[:unread_ims], conversations_api, formatter)
183
- display_unread_channels(workspace, data[:unread_channels], conversations_api, formatter)
190
+ display_unread_group_dms(workspace, data[:unread_group_dms], conversations_api, formatter)
191
+ display_unread_channels(workspace, data, conversations_api, formatter)
184
192
  show_threads(workspace, formatter)
185
193
  end
186
194
 
187
195
  def display_unread_dms(workspace, unread_ims, conversations_api, formatter)
188
196
  unread_ims.each do |im|
189
- mention_count = im['mention_count'] || 0
190
197
  user_name = resolve_dm_user_name(workspace, im['id'], conversations_api)
191
- puts
192
- puts output.bold("@#{user_name}") + (mention_count.positive? ? " (#{mention_count} mentions)" : '')
193
- puts
198
+ print_conversation_header("@#{user_name}", im['mention_count'] || 0)
194
199
  show_channel_messages(workspace, im['id'], @options[:limit], conversations_api, formatter)
195
200
  end
196
201
  end
197
202
 
198
- def display_unread_channels(workspace, unreads, conversations_api, formatter)
199
- return puts('No unread messages') if unreads.empty?
203
+ def display_unread_group_dms(workspace, group_dms, conversations_api, formatter)
204
+ group_dms.each do |group_dm|
205
+ print_conversation_header(group_dm_label.label(workspace, group_dm['id']), group_dm['mention_count'] || 0)
206
+ show_channel_messages(workspace, group_dm['id'], @options[:limit], conversations_api, formatter)
207
+ end
208
+ end
209
+
210
+ def print_conversation_header(label, mention_count)
211
+ puts
212
+ puts output.bold(label) + (mention_count.positive? ? " (#{mention_count} mentions)" : '')
213
+ puts
214
+ end
215
+
216
+ # "No unread messages" only when nothing at all is unread, not merely
217
+ # when the DMs and group DMs above are the only unreads.
218
+ def display_unread_channels(workspace, data, conversations_api, formatter)
219
+ unreads = data[:unread_channels]
220
+ return puts('No unread messages') if [unreads, data[:unread_ims], data[:unread_group_dms]].all?(&:empty?)
200
221
 
201
222
  unreads.each { |ch| display_channel(workspace, ch, conversations_api, formatter) }
202
223
  end
@@ -283,12 +304,13 @@ module Slk
283
304
  channel_id = resolve_channel_id(workspace, channel_name)
284
305
  return unless unread_marker(workspace).mark_single_channel(channel_id)
285
306
 
286
- success("Marked ##{channel_name} as read on #{workspace.name}")
307
+ success("Marked ##{channel_name.delete_prefix('#')} as read on #{workspace.name}")
287
308
  end
288
309
 
289
310
  def clear_all_channels(workspace)
290
311
  counts = unread_marker(workspace).mark_all(options: { muted: @options[:muted] })
291
- success("Cleared #{counts[:channels]} channels and #{counts[:threads]} threads on #{workspace.name}")
312
+ success("Cleared #{counts[:dms]} DMs, #{counts[:group_dms]} group DMs, #{counts[:channels]} channels " \
313
+ "and #{counts[:threads]} threads on #{workspace.name}")
292
314
  end
293
315
 
294
316
  def unread_marker(workspace)
@@ -305,15 +327,8 @@ module Slk
305
327
  return channel_name if channel_name.match?(/^[CDG][A-Z0-9]+$/)
306
328
 
307
329
  name = channel_name.delete_prefix('#')
308
- cache_store.get_channel_id(workspace.name, name) || resolve_channel(workspace, name)
309
- end
310
-
311
- def resolve_channel(workspace, name)
312
- api = runner.conversations_api(workspace.name)
313
- response = api.list
314
- channels = response['channels'] || []
315
- channel = channels.find { |c| c['name'] == name }
316
- channel&.dig('id') || raise(ConfigError, "Channel not found: ##{name}")
330
+ Services::ChannelLookup.new(runner: runner, cache_store: cache_store).find_id(workspace, name) ||
331
+ raise(ConfigError, "Channel not found: ##{name}")
317
332
  end
318
333
  end
319
334
  # rubocop:enable Metrics/ClassLength
@@ -44,6 +44,8 @@ module Slk
44
44
  @handle | name Resolved via user cache
45
45
  Uxxx Raw user ID
46
46
 
47
+ Huddle state is read live and shown when Slack has one set.
48
+
47
49
  OPTIONS
48
50
  --full Section-grouped layout (Contact, People, About me)
49
51
  --json Raw JSON output
@@ -79,8 +81,26 @@ module Slk
79
81
  end
80
82
 
81
83
  def load_profile(workspace, user_id)
82
- runner.profile_resolver(workspace.name, refresh: @options[:refresh])
83
- .resolve_with_people(user_id)
84
+ profile = runner.profile_resolver(workspace.name, refresh: @options[:refresh])
85
+ .resolve_with_people(user_id)
86
+ attach_huddle_channel(workspace, profile)
87
+ end
88
+
89
+ # Channel ids are useless on a card. A failed lookup still leaves the
90
+ # state, which is the part Slack always sends.
91
+ def attach_huddle_channel(workspace, profile)
92
+ return profile if profile.huddle_channel_id.to_s.empty?
93
+
94
+ label = huddle_channels.label(workspace, profile.huddle_channel_id)
95
+ return profile unless label
96
+
97
+ Models::Profile.new(**profile.to_h, huddle_channel: label)
98
+ end
99
+
100
+ def huddle_channels
101
+ @huddle_channels ||= Services::HuddleChannelLabel.new(
102
+ runner: runner, cache_store: cache_store, on_debug: ->(message) { debug(message) }
103
+ )
84
104
  end
85
105
 
86
106
  def render_profiles(profiles)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Text for `slk huddles`. A huddle Slack did not attach to a channel is
6
+ # the names plus "channel unknown", not a guessed conversation.
7
+ class HuddleFormatter
8
+ def initialize(output)
9
+ @output = output
10
+ end
11
+
12
+ def render(reports, scope: nil)
13
+ reports.each_with_index do |report, index|
14
+ @output.puts if index.positive?
15
+ render_report(report, header: reports.size > 1, scope: reports.one? ? scope : nil)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def render_report(report, header:, scope:)
22
+ @output.puts(@output.bold(report.workspace)) if header
23
+ @output.puts(summary(report.huddles, scope))
24
+ report.huddles.each { |huddle| render_huddle(huddle) }
25
+ end
26
+
27
+ def summary(huddles, scope)
28
+ return "#{with_scope('No active huddles', scope)}." if huddles.empty?
29
+
30
+ noun = huddles.one? ? 'huddle' : 'huddles'
31
+ with_scope("#{huddles.size} active #{noun}", scope)
32
+ end
33
+
34
+ def with_scope(text, scope)
35
+ scope ? "#{text} on #{scope}" : text
36
+ end
37
+
38
+ def render_huddle(huddle)
39
+ @output.puts
40
+ huddle.place ? render_placed(huddle) : render_unplaced(huddle)
41
+ end
42
+
43
+ def render_placed(huddle)
44
+ @output.puts(@output.bold(huddle.place))
45
+ huddle.participants.each { |person| @output.puts(" #{person.name}") }
46
+ end
47
+
48
+ def render_unplaced(huddle)
49
+ @output.puts(huddle.participants.map(&:name).join(', '))
50
+ @output.puts(" #{@output.gray('channel unknown')}")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -87,7 +87,7 @@ module Slk
87
87
  workspace_name = workspace&.name
88
88
  return unless workspace_name
89
89
 
90
- channel_name = @cache.get_channel_name(workspace_name, options[:channel_id])
90
+ channel_name = options[:channel_name] || @cache.get_channel_name(workspace_name, options[:channel_id])
91
91
  result[:channel_name] = channel_name if channel_name
92
92
  end
93
93
  end