slk 0.14.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49e4f58fdf9277102ef5ac4903270d820c4bd2f375bf6255a03efea91859de53
4
- data.tar.gz: 86b10dfa38811794ec3d45aeed3ecf9c824f9f547c3149b0f43b4ae61cbeb60a
3
+ metadata.gz: '080abcf262e2c943cb01e3fe6fc5619ee0921d93d4418deeb5f42d6fc4154a92'
4
+ data.tar.gz: 762989ed0de46ef13372d550a80517bf2c3c7ce73eaf916c8d78fafd5d6e6f57
5
5
  SHA512:
6
- metadata.gz: 26de21e406bb3185e64ee5db5ced45e964c0ccd5228369a869902f4dbf871d1185aace9ac132433c4ed384185a38f7ffd9a4670fb6dfd5bba269c4c539f1f5b7
7
- data.tar.gz: a90f52d2c400a48d2bd0ea4b87c6219ba689642ac6a13c73be11a5fee7164bc4783f17f628045d678d906a1d743629255a50a5d1ded4d9e2216b0d00e9f3cf55
6
+ metadata.gz: bc5bc2aafe45a6ab8a20fac51086e426d2586012be06b82d87fa3560597386b6d0a504a1489b8b75cf9af5c00627dd7b10a2714af8479fee37202408963e10b8
7
+ data.tar.gz: a835b18a0a8155c73d2618fa484393dcfaf9b11fbbd0583530209b810e0cadd09ddf45fa5618c54a75f9581dfd0a165d35257cd6faa0add128c08a51d03b0047
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ 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
+
10
30
  ## [0.14.0] - 2026-10-09
11
31
 
12
32
  ### 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
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
@@ -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
@@ -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
@@ -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
@@ -110,16 +110,23 @@ module Slk
110
110
  result
111
111
  end
112
112
 
113
+ # Group DM mpdm-* slugs are never returned (older versions cached them),
114
+ # so callers fall through to a real label.
113
115
  def get_channel_name(workspace_name, channel_id)
114
116
  load_channel_cache(workspace_name)
115
117
  cache = @channel_cache[workspace_name] || {}
116
- result = cache.key(channel_id)
118
+ result = cache.find { |name, id| id == channel_id && !group_dm_slug?(name) }&.first
117
119
  log_cache_access('channel_name', workspace_name, channel_id, result)
118
120
  result
119
121
  end
120
122
 
123
+ # Refuses group DM mpdm-* slugs: they are Slack's internal names, not
124
+ # what people type or see. Writing drops any slugs cached before this.
121
125
  def set_channel(workspace_name, channel_name, channel_id)
122
- @channel_cache[workspace_name] ||= {}
126
+ return if group_dm_slug?(channel_name)
127
+
128
+ load_channel_cache(workspace_name)
129
+ @channel_cache[workspace_name].reject! { |name, _id| group_dm_slug?(name) }
123
130
  @channel_cache[workspace_name][channel_name] = channel_id
124
131
  save_channel_cache(workspace_name)
125
132
  end
@@ -184,6 +191,10 @@ module Slk
184
191
 
185
192
  private
186
193
 
194
+ def group_dm_slug?(name)
195
+ name.to_s.match?(Support::GroupDmName::SLUG)
196
+ end
197
+
187
198
  def load_user_cache(workspace_name)
188
199
  return if @user_cache.key?(workspace_name)
189
200
 
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Finds a conversation id from a name typed as `#name`.
6
+ #
7
+ # Order: the channel cache, then every page of the conversations you
8
+ # belong to (users.conversations: public and private channels plus group
9
+ # DMs, so a named group DM such as "ifams review" resolves), then one page
10
+ # of conversations.list for public channels you have not joined. That
11
+ # last step is only one page, as before: the full workspace list runs to
12
+ # thousands of channels at 100 per page.
13
+ #
14
+ # A channel wins over a named group DM with the same name. A group DM is
15
+ # cached only after a full scan finds no channel of that name among yours.
16
+ class ChannelLookup
17
+ MEMBER_TYPES = 'public_channel,private_channel,mpim'
18
+ MAX_PAGES = 50
19
+
20
+ def initialize(runner:, cache_store:)
21
+ @runner = runner
22
+ @cache = cache_store
23
+ end
24
+
25
+ # @param workspace [Models::Workspace]
26
+ # @param name [String] channel or group DM name, without the leading #
27
+ # @return [String, nil] the conversation id, or nil if none matches
28
+ def find_id(workspace, name)
29
+ @cache.get_channel_id(workspace.name, name) || lookup(workspace, name)
30
+ end
31
+
32
+ private
33
+
34
+ def lookup(workspace, name)
35
+ channel = member_conversation(workspace, name) || listed_channel(workspace, name)
36
+ return nil unless channel
37
+
38
+ @cache.set_channel(workspace.name, channel['name'], channel['id'])
39
+ channel['id']
40
+ end
41
+
42
+ def member_conversation(workspace, name)
43
+ group_dm = nil
44
+ each_member_page(workspace) do |conversations|
45
+ conversations.each do |conversation|
46
+ next unless name_matches?(conversation, name)
47
+ return conversation unless conversation['is_mpim']
48
+
49
+ group_dm ||= conversation
50
+ end
51
+ end
52
+ group_dm
53
+ end
54
+
55
+ def each_member_page(workspace)
56
+ api = @runner.users_api(workspace.name)
57
+ cursor = nil
58
+ MAX_PAGES.times do
59
+ response = api.conversations(cursor: cursor, types: MEMBER_TYPES, exclude_archived: true)
60
+ yield response['channels'] || []
61
+ cursor = response.dig('response_metadata', 'next_cursor').to_s
62
+ break if cursor.empty?
63
+ end
64
+ end
65
+
66
+ def listed_channel(workspace, name)
67
+ channels = @runner.conversations_api(workspace.name).list['channels'] || []
68
+ channels.find { |channel| name_matches?(channel, name) }
69
+ end
70
+
71
+ # Channel names are lowercase; a group DM's name keeps whatever case
72
+ # it was given, so "#IFAMS Review" and "#ifams review" both find it.
73
+ def name_matches?(conversation, name)
74
+ conversation['name'].to_s.casecmp?(name)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Header label for a group DM in unread and catchup (conversation and
6
+ # thread headers). A named group DM
7
+ # shows its name ("#ifams review"); any other shows the people in it
8
+ # besides you ("@Ada Lovelace, Grace Hopper") rather than the opaque
9
+ # mpdm-* slug. One conversations.info per chat, remembered for the run.
10
+ class GroupDmLabel
11
+ def initialize(runner:, on_debug: nil)
12
+ @runner = runner
13
+ @on_debug = on_debug
14
+ @labels = {}
15
+ @self_ids = {}
16
+ end
17
+
18
+ # @return [String] "#name", "@names", or "#<channel id>" if Slack refuses
19
+ def label(workspace, channel_id)
20
+ @labels[[workspace.name, channel_id]] ||= fetch_label(workspace, channel_id)
21
+ end
22
+
23
+ # @return [String, nil] a label already worked out this run, if any
24
+ def known(workspace, channel_id)
25
+ @labels[[workspace.name, channel_id]]
26
+ end
27
+
28
+ # Same label when the caller already holds conversations.info's channel.
29
+ def label_from(workspace, channel_id, channel)
30
+ @labels[[workspace.name, channel_id]] ||= label_for(workspace, channel) || "##{channel_id}"
31
+ end
32
+
33
+ private
34
+
35
+ def fetch_label(workspace, channel_id)
36
+ channel = @runner.conversations_api(workspace.name).info(channel: channel_id)['channel'] || {}
37
+ label_for(workspace, channel) || "##{channel_id}"
38
+ rescue ApiError => e
39
+ @on_debug&.call("Group DM lookup failed for #{channel_id}: #{e.message}")
40
+ "##{channel_id}"
41
+ end
42
+
43
+ def label_for(workspace, channel)
44
+ named = Support::GroupDmName.custom(channel)
45
+ return "##{named}" if named
46
+
47
+ names = member_names(workspace, channel['members'] || [])
48
+ return "@#{names.join(', ')}" if names.any?
49
+
50
+ "##{channel['name']}" unless channel['name'].to_s.empty?
51
+ end
52
+
53
+ def member_names(workspace, members)
54
+ self_id = self_user_id(workspace)
55
+ others = members.reject { |id| id == self_id }
56
+ others = members if others.empty?
57
+ others.map { |id| user_name(workspace, id) }
58
+ end
59
+
60
+ def user_name(workspace, user_id)
61
+ UserLookup.new(cache_store: @runner.cache_store, workspace: workspace,
62
+ api_client: @runner.api_client, on_debug: @on_debug).resolve_name_or_bot(user_id) || user_id
63
+ end
64
+
65
+ # Same meta key the sent and who commands use, so one auth.test serves all.
66
+ def self_user_id(workspace)
67
+ @self_ids.fetch(workspace.name) do
68
+ @self_ids[workspace.name] = @runner.cache_store.get_meta(workspace.name, 'self_user_id') ||
69
+ fetch_self_user_id(workspace)
70
+ end
71
+ end
72
+
73
+ def fetch_self_user_id(workspace)
74
+ id = @runner.client_api(workspace.name).auth_test['user_id']
75
+ @runner.cache_store.set_meta(workspace.name, 'self_user_id', id) if id
76
+ id
77
+ rescue ApiError => e
78
+ @on_debug&.call("Could not identify self in #{workspace.name}: #{e.message}")
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
@@ -38,11 +38,19 @@ module Slk
38
38
 
39
39
  def label_from(workspace, channel_id, channel)
40
40
  return 'DM' if channel['is_im']
41
- return 'group DM' if channel['is_mpim']
41
+ return group_label(channel) if channel['is_mpim']
42
42
 
43
43
  named_label(workspace, channel_id, channel['name'])
44
44
  end
45
45
 
46
+ # A named group DM shows its name but stays out of the channel cache:
47
+ # the name can be changed at any time and can match a real channel's
48
+ # name, and the cache maps names to ids, so `#name` would go to the chat.
49
+ def group_label(channel)
50
+ name = Support::GroupDmName.custom(channel)
51
+ name ? "##{name}" : 'group DM'
52
+ end
53
+
46
54
  def named_label(workspace, channel_id, name)
47
55
  return nil if name.to_s.empty?
48
56
 
@@ -5,6 +5,7 @@ module Slk
5
5
  # Resolves sent-conversation destinations while retaining raw channel IDs
6
6
  # and names in JSON. Group DMs need member lookup: their Slack name is an
7
7
  # opaque mpdm-* slug, not a user ID that MentionReplacer can resolve.
8
+ # Named group DMs ("named chats") skip that and show as #name.
8
9
  class SentChannelLabel
9
10
  USER_ID_PATTERN = /\A[UW][A-Z0-9]+\z/
10
11
 
@@ -28,6 +29,9 @@ module Slk
28
29
  private
29
30
 
30
31
  def group_label(workspace, channel_id, name, self_user_id, self_username)
32
+ named = Support::GroupDmName.label(name)
33
+ return named if named
34
+
31
35
  names = group_member_names(workspace, channel_id, self_user_id)
32
36
  return "@#{names.join(', ')}" if names.any?
33
37
 
@@ -66,19 +66,8 @@ module Slk
66
66
  end
67
67
 
68
68
  def resolve_channel(workspace, name)
69
- cached = @cache.get_channel_id(workspace.name, name)
70
- return cached if cached
71
-
72
- fetch_and_cache_channel(workspace, name)
73
- end
74
-
75
- def fetch_and_cache_channel(workspace, name)
76
- channels = @runner.conversations_api(workspace.name).list['channels'] || []
77
- channel = channels.find { |c| c['name'] == name }
78
- raise ConfigError, "Channel not found: ##{name}" unless channel
79
-
80
- @cache.set_channel(workspace.name, name, channel['id'])
81
- channel['id']
69
+ ChannelLookup.new(runner: @runner, cache_store: @cache).find_id(workspace, name) ||
70
+ raise(ConfigError, "Channel not found: ##{name}")
82
71
  end
83
72
 
84
73
  def resolve_dm(workspace, username)
@@ -14,13 +14,15 @@ module Slk
14
14
 
15
15
  # Mark all unreads in a workspace, return counts
16
16
  # @param options [Hash] :muted - include muted channels
17
- # @return [Hash] { dms: count, channels: count, threads: count }
17
+ # @return [Hash] { dms: count, group_dms: count, channels: count, threads: count }
18
18
  def mark_all(options: {})
19
19
  counts = @client.counts
20
+ muted_ids = options[:muted] ? [] : @users.muted_channels
20
21
 
21
22
  {
22
23
  dms: mark_dms(counts['ims'] || []),
23
- channels: mark_channels(counts['channels'] || [], muted: options[:muted]),
24
+ group_dms: mark_channels(counts['mpims'] || [], muted_ids),
25
+ channels: mark_channels(counts['channels'] || [], muted_ids),
24
26
  threads: mark_threads
25
27
  }
26
28
  end
@@ -44,13 +46,14 @@ module Slk
44
46
  count
45
47
  end
46
48
 
47
- def mark_channels(channels, muted: false)
48
- muted_ids = muted ? [] : @users.muted_channels
49
+ # Channels and group DMs (client.counts `mpims`): a muted group DM is
50
+ # skipped the same way a muted channel is.
51
+ def mark_channels(channels, muted_ids)
49
52
  count = 0
50
53
 
51
54
  channels.each do |channel|
52
55
  next unless channel['has_unreads']
53
- next if !muted && muted_ids.include?(channel['id'])
56
+ next if muted_ids.include?(channel['id'])
54
57
 
55
58
  count += 1 if mark_conversation(channel['id'])
56
59
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Support
5
+ # Group DMs can be given a name in Slack ("named chats"). They are still
6
+ # mpims, but `name` holds what the user typed ("ifams review") rather
7
+ # than the generated mpdm-alice--bob-1 slug. conversations.info flags them
8
+ # with properties.has_custom_mpdm_name; search.messages sends no
9
+ # properties, so a name that isn't an mpdm-* slug counts as custom too.
10
+ module GroupDmName
11
+ SLUG = /\Ampdm-/
12
+
13
+ module_function
14
+
15
+ # @param channel [Hash] conversation payload from Slack
16
+ # @return [String, nil] the custom name, or nil for anything else
17
+ def custom(channel)
18
+ return nil unless channel.is_a?(Hash) && channel['is_mpim']
19
+
20
+ name = channel['name'].to_s
21
+ return nil if name.empty?
22
+ return name if channel.dig('properties', 'has_custom_mpdm_name')
23
+
24
+ custom_name?(name) ? name : nil
25
+ end
26
+
27
+ # @param name [String, nil] a group DM's Slack name
28
+ # @return [Boolean] true when the name was set by a person
29
+ def custom_name?(name)
30
+ !name.to_s.empty? && !name.to_s.match?(SLUG)
31
+ end
32
+
33
+ # @return [String, nil] "#name" for a named group DM, else nil
34
+ def label(name)
35
+ "##{name}" if custom_name?(name)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -68,6 +68,8 @@ module Slk
68
68
  public
69
69
 
70
70
  # Resolves a channel ID to a formatted label (@username or #channel)
71
+ # Group DMs get "#name" when named, else "@other, members", never the
72
+ # mpdm-* slug that other lookups may have put in the channel cache.
71
73
  # @param workspace [Models::Workspace] The workspace to look up in
72
74
  # @param channel_id [String] The channel ID
73
75
  # @return [String] Formatted label like "@username" or "#channel"
@@ -97,8 +99,11 @@ module Slk
97
99
  end
98
100
 
99
101
  def resolve_channel_label(workspace, channel_id)
102
+ known = group_dm_label.known(workspace, channel_id)
103
+ return known if known
104
+
100
105
  cached_name = cache_store.get_channel_name(workspace.name, channel_id)
101
- return "##{cached_name}" if cached_name
106
+ return "##{cached_name}" if cached_name && !cached_name.match?(Support::GroupDmName::SLUG)
102
107
 
103
108
  fetch_channel_label(workspace, channel_id)
104
109
  end
@@ -108,16 +113,27 @@ module Slk
108
113
  response = conversations.info(channel: channel_id)
109
114
  return "##{channel_id}" unless response['ok'] && response['channel']
110
115
 
111
- name = response['channel']['name']
112
- return "##{channel_id}" unless name
116
+ channel = response['channel']
117
+ return group_dm_label.label_from(workspace, channel_id, channel) if channel['is_mpim']
113
118
 
114
- cache_store.set_channel(workspace.name, name, channel_id)
115
- "##{name}"
119
+ named_channel_label(workspace, channel_id, channel['name'])
116
120
  rescue ApiError => e
117
121
  debug("Channel info lookup failed for #{channel_id}: #{e.message}")
118
122
  "##{channel_id}"
119
123
  end
120
124
 
125
+ def named_channel_label(workspace, channel_id, name)
126
+ return "##{channel_id}" unless name
127
+
128
+ cache_store.set_channel(workspace.name, name, channel_id)
129
+ "##{name}"
130
+ end
131
+
132
+ # One per command run, so a group DM is looked up once for all headers.
133
+ def group_dm_label
134
+ @group_dm_label ||= Services::GroupDmLabel.new(runner: runner, on_debug: ->(msg) { debug(msg) })
135
+ end
136
+
121
137
  def name_from_user_profile(msg)
122
138
  return nil unless msg['user_profile']
123
139
 
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.14.0'
4
+ VERSION = '0.15.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -125,6 +125,8 @@ module Slk
125
125
  autoload :HuddleState, 'slk/services/huddle_state'
126
126
  autoload :WorkspaceFanout, 'slk/services/workspace_fanout'
127
127
  autoload :HuddleChannelLabel, 'slk/services/huddle_channel_label'
128
+ autoload :GroupDmLabel, 'slk/services/group_dm_label'
129
+ autoload :ChannelLookup, 'slk/services/channel_lookup'
128
130
  autoload :ThreadSubscription, 'slk/services/thread_subscription'
129
131
  end
130
132
 
@@ -209,6 +211,7 @@ module Slk
209
211
  autoload :HelpFormatter, 'slk/support/help_formatter'
210
212
  autoload :ErrorLogger, 'slk/support/error_logger'
211
213
  autoload :UserResolver, 'slk/support/user_resolver'
214
+ autoload :GroupDmName, 'slk/support/group_dm_name'
212
215
  autoload :TextWrapper, 'slk/support/text_wrapper'
213
216
  autoload :InteractivePrompt, 'slk/support/interactive_prompt'
214
217
  autoload :DateParser, 'slk/support/date_parser'
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.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -109,6 +109,7 @@ files:
109
109
  - lib/slk/services/activity_enricher.rb
110
110
  - lib/slk/services/api_client.rb
111
111
  - lib/slk/services/cache_store.rb
112
+ - lib/slk/services/channel_lookup.rb
112
113
  - lib/slk/services/configuration.rb
113
114
  - lib/slk/services/deactivation_scanner.rb
114
115
  - lib/slk/services/emoji_downloader.rb
@@ -116,6 +117,7 @@ files:
116
117
  - lib/slk/services/encryption.rb
117
118
  - lib/slk/services/file_downloader.rb
118
119
  - lib/slk/services/gemoji_sync.rb
120
+ - lib/slk/services/group_dm_label.rb
119
121
  - lib/slk/services/huddle_channel_label.rb
120
122
  - lib/slk/services/huddle_fetch.rb
121
123
  - lib/slk/services/huddle_scanner.rb
@@ -149,6 +151,7 @@ files:
149
151
  - lib/slk/support/date_parser.rb
150
152
  - lib/slk/support/decimal_timestamp.rb
151
153
  - lib/slk/support/error_logger.rb
154
+ - lib/slk/support/group_dm_name.rb
152
155
  - lib/slk/support/help_formatter.rb
153
156
  - lib/slk/support/image_preview.rb
154
157
  - lib/slk/support/inline_images.rb