slk 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49e4f58fdf9277102ef5ac4903270d820c4bd2f375bf6255a03efea91859de53
4
- data.tar.gz: 86b10dfa38811794ec3d45aeed3ecf9c824f9f547c3149b0f43b4ae61cbeb60a
3
+ metadata.gz: 5edea2fd65213e0875f06f53729966f8658ae880bd38321bd9a9d8980200c07a
4
+ data.tar.gz: 7a532e0e1991807962aae21099ad2e9c2c98b03a364dc3e6db90ebf7f2fdabf6
5
5
  SHA512:
6
- metadata.gz: 26de21e406bb3185e64ee5db5ced45e964c0ccd5228369a869902f4dbf871d1185aace9ac132433c4ed384185a38f7ffd9a4670fb6dfd5bba269c4c539f1f5b7
7
- data.tar.gz: a90f52d2c400a48d2bd0ea4b87c6219ba689642ac6a13c73be11a5fee7164bc4783f17f628045d678d906a1d743629255a50a5d1ded4d9e2216b0d00e9f3cf55
6
+ metadata.gz: 256c73b33433f9488bfcd1333c14f57e953e80fea54a07500239933cdf2129e682beb5d18cc3ab6f730d77570fd103ea77ab2b2ad7f2f8386d80924e6240f5e1
7
+ data.tar.gz: 388ffeef9e34949a549fb4cc7bcd8560e7132ad9ebde68b189c7dccfac5b749e3e21445c41fea2f614e5a3f1f179101b45b15727a219a3ab289d916aa0cb4f8a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ 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
+
20
+ ## [0.15.0] - 2026-10-13
21
+
22
+ ### Added
23
+
24
+ - 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.
25
+ - `slk debug channel <id|url|#name>` (hidden command) prints the raw `conversations.info` response.
26
+
27
+ ### Fixed
28
+
29
+ - `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}`.
30
+ - `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.
31
+ - 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.
32
+ - `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.
33
+ - `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.
34
+ - `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.
35
+ - 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`.
36
+ - Writing to the channel cache before reading from it no longer replaces the file with just the new entry.
37
+ - `slk unread` no longer prints "No unread messages" after listing unread DMs when no channel is unread.
38
+ - `slk unread clear "#general"` printed "Marked ##general"; it now prints one `#`.
39
+
10
40
  ## [0.14.0] - 2026-10-09
11
41
 
12
42
  ### 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
@@ -229,9 +232,11 @@ slk huddles --json # Call id, channel, and participants
229
232
  ```
230
233
 
231
234
  Slack has no huddle list. `slk huddles` reads `huddle_state` from `users.list`
232
- and groups people who share a call id. A huddle with no channel is one Slack
233
- did not attach to a conversation. People outside the workspace, including some
234
- 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
235
240
  fetched live and is not cached. `--all` scans every workspace at the same time.
236
241
  One connection can only read one roster, so each workspace gets its own.
237
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
data/lib/slk/api/users.rb CHANGED
@@ -143,9 +143,11 @@ module Slk
143
143
 
144
144
  public
145
145
 
146
- def conversations(cursor: nil, limit: 1000)
147
- 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 }
148
149
  params[:cursor] = cursor if cursor
150
+ params[:exclude_archived] = true if exclude_archived
149
151
  @api.post_form(@workspace, 'users.conversations', params)
150
152
  end
151
153
  end
@@ -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
@@ -25,11 +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)
28
+ in [action, target] if TARGET_ACTIONS.key?(action) then send(TARGET_ACTIONS[action], target)
29
29
  else unknown_action
30
30
  end
31
31
  end
32
32
 
33
+ TARGET_ACTIONS = { 'thread' => :dump_thread, 'channel' => :dump_channel, 'huddle' => :dump_huddle }.freeze
34
+
33
35
  private
34
36
 
35
37
  def dump_profile(user_input)
@@ -61,7 +63,7 @@ module Slk
61
63
  # carry `subscribed`/`last_read`?) and subscriptions.thread.get. Each call
62
64
  # is captured separately so one failing does not hide the other.
63
65
  def dump_thread(url)
64
- resolved = resolve_thread(url)
66
+ resolved = resolve_target(url)
65
67
  ts = resolved.thread_ts || resolved.msg_ts
66
68
  raise UsageError, 'URL must point to a specific message' unless ts
67
69
 
@@ -69,9 +71,38 @@ module Slk
69
71
  0
70
72
  end
71
73
 
72
- def resolve_thread(url)
74
+ # Raw conversations.info: shows the flags labels key off (is_mpim,
75
+ # properties.has_custom_mpdm_name for named group DMs, is_ext_shared).
76
+ def dump_channel(target)
77
+ resolved = resolve_target(target)
78
+ info = runner.conversations_api(resolved.workspace.name).info(channel: resolved.channel_id)
79
+ output.puts(JSON.pretty_generate(info))
80
+ 0
81
+ end
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
+
103
+ def resolve_target(target)
73
104
  Services::TargetResolver.new(runner: runner, cache_store: cache_store)
74
- .resolve(url, default_workspace: runner.workspace(@options[:workspace]))
105
+ .resolve(target, default_workspace: runner.workspace(@options[:workspace]))
75
106
  end
76
107
 
77
108
  def thread_calls(workspace_name, channel, thread_ts)
@@ -118,7 +149,7 @@ module Slk
118
149
 
119
150
  def unknown_action
120
151
  error("Unknown debug action: #{positional_args.first.inspect}")
121
- error('Valid actions: profile [user], team, schema, thread <url>')
152
+ error('Valid actions: profile [user], team, schema, thread <url>, channel <id|url>, huddle <call|user>')
122
153
  1
123
154
  end
124
155
 
@@ -135,6 +166,8 @@ module Slk
135
166
  team Dump team.info
136
167
  schema Dump team.profile.get
137
168
  thread <url> Dump thread parent + subscriptions.thread.get
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
138
171
 
139
172
  USER
140
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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