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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +21 -0
- data/lib/slk/api/users.rb +19 -5
- data/lib/slk/cli.rb +1 -0
- data/lib/slk/commands/activity.rb +6 -0
- data/lib/slk/commands/catchup.rb +29 -11
- data/lib/slk/commands/debug.rb +15 -4
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/huddles.rb +94 -0
- data/lib/slk/commands/messages.rb +20 -3
- data/lib/slk/commands/thread.rb +1 -2
- data/lib/slk/commands/unread.rb +34 -19
- data/lib/slk/commands/who.rb +22 -2
- data/lib/slk/formatters/huddle_formatter.rb +54 -0
- data/lib/slk/formatters/json_message_formatter.rb +1 -1
- data/lib/slk/formatters/mention_replacer.rb +9 -3
- data/lib/slk/formatters/profile_formatter.rb +9 -0
- data/lib/slk/formatters/profile_rows.rb +14 -1
- data/lib/slk/formatters/search_formatter.rb +4 -0
- data/lib/slk/models/huddle.rb +17 -0
- data/lib/slk/models/profile.rb +16 -1
- data/lib/slk/models/search_result.rb +3 -0
- data/lib/slk/services/activity_enricher.rb +33 -13
- data/lib/slk/services/api_client.rb +58 -2
- data/lib/slk/services/cache_store.rb +13 -2
- data/lib/slk/services/channel_lookup.rb +78 -0
- data/lib/slk/services/group_dm_label.rb +83 -0
- data/lib/slk/services/huddle_channel_label.rb +62 -0
- data/lib/slk/services/huddle_fetch.rb +90 -0
- data/lib/slk/services/huddle_scanner.rb +101 -0
- data/lib/slk/services/huddle_state.rb +41 -0
- data/lib/slk/services/profile_builder.rb +1 -0
- data/lib/slk/services/profile_resolver.rb +1 -1
- data/lib/slk/services/sent_channel_label.rb +4 -0
- data/lib/slk/services/target_resolver.rb +2 -13
- data/lib/slk/services/unread_marker.rb +8 -5
- data/lib/slk/services/workspace_fanout.rb +28 -0
- data/lib/slk/support/group_dm_name.rb +39 -0
- data/lib/slk/support/user_resolver.rb +21 -5
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +13 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '080abcf262e2c943cb01e3fe6fc5619ee0921d93d4418deeb5f42d6fc4154a92'
|
|
4
|
+
data.tar.gz: 762989ed0de46ef13372d550a80517bf2c3c7ce73eaf916c8d78fafd5d6e6f57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
75
|
-
|
|
85
|
+
page = response['members'] || []
|
|
86
|
+
total += page.size
|
|
87
|
+
yield page, total
|
|
76
88
|
cursor = next_cursor(response, cursor)
|
|
77
|
-
break
|
|
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
|
-
|
|
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
|
@@ -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,
|
data/lib/slk/commands/catchup.rb
CHANGED
|
@@ -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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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:
|
|
159
|
-
empty:
|
|
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
|
-
|
|
168
|
-
|
|
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
|
data/lib/slk/commands/debug.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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(
|
|
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
|
data/lib/slk/commands/help.rb
CHANGED
|
@@ -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)
|
data/lib/slk/commands/thread.rb
CHANGED
|
@@ -130,8 +130,7 @@ module Slk
|
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
def channel_label(workspace, channel_id)
|
|
133
|
-
|
|
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)
|
data/lib/slk/commands/unread.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
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[:
|
|
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
|
-
|
|
309
|
-
|
|
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
|
data/lib/slk/commands/who.rb
CHANGED
|
@@ -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
|
-
|
|
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
|