slk 0.8.0 → 0.9.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 +13 -0
- data/README.md +30 -0
- data/lib/slk/api/users.rb +27 -0
- data/lib/slk/cli.rb +2 -1
- data/lib/slk/commands/deactivations.rb +285 -0
- data/lib/slk/commands/help.rb +25 -18
- data/lib/slk/formatters/deactivation_formatter.rb +108 -0
- data/lib/slk/models/deactivation.rb +77 -0
- data/lib/slk/services/deactivation_scanner.rb +105 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c56010125fc8e78293e4701ac5619742dda1fecd3e6e06ec98a3eb6afab0dc46
|
|
4
|
+
data.tar.gz: 05fad0411c6b216091bc9dd7cade46a36579bbba951042c61859111c57db9356
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ae292c3c50580e4872ec7a2334052b2495d0df8943341970d98f0a1922ea5b15d9612a0b8998de30bfb100100d3daf0567c9270fafef89529fc88c65f72634d
|
|
7
|
+
data.tar.gz: e86c76ce090e6a622a2122c068fb8a384520ff7b38193613f254f446f4a4165f09c48bcf944b2f3d5a2ad3435a0e71502bda8a6ec1b776b0b1b4bbc1ee240424
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.9.0] - 2026-09-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`slk deactivations`** — who left the workspace, and when
|
|
13
|
+
- Slack has no "who left" endpoint. It does have `users.list`, which returns departed accounts with `deleted: true` and an `updated` epoch for the last change to the account — and for a deactivated account that change is almost always the deactivation itself. The help text says so out loud, because an admin who edits a departed profile afterwards moves the date forward, and a date that looks authoritative but is not should say which it is
|
|
14
|
+
- Defaults to the 25 most recent departures, one per line with a full ISO date, so the output stays greppable. `-n 0` shows all of them
|
|
15
|
+
- `slk deactivations 90d` (or `--since 90d`, `--since 2026-01-01`) narrows to a window; `--grep` filters across name, handle, title, email and user ID
|
|
16
|
+
- `--chart` draws departures per calendar month across the whole window asked for, quiet months filled in with zero — a gap in a histogram should read as "nobody left", not as a month that never happened, and a quiet month at either end of the window still happened. Without `--since` it covers the last twelve months rather than the entire history of the workspace
|
|
17
|
+
- An account Slack never dated cannot answer a question about a window, so it drops out of one — but the footer says how many did, rather than discarding them in silence
|
|
18
|
+
- Bots and app users are excluded from the counts and the list; `--bots` puts them back
|
|
19
|
+
- The roster is one API call per 1000 members, so the derived result is cached for six hours. The footer says how old it is, because "nobody left this week" and "nobody left since the last time you asked" are different statements. `--refresh` re-fetches
|
|
20
|
+
|
|
8
21
|
## [0.8.0] - 2026-08-30
|
|
9
22
|
|
|
10
23
|
### Added
|
data/README.md
CHANGED
|
@@ -210,6 +210,36 @@ slk cache populate # Pre-populate user cache
|
|
|
210
210
|
slk cache clear # Clear all caches
|
|
211
211
|
```
|
|
212
212
|
|
|
213
|
+
### Deactivations
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
slk deactivations # 25 most recent departures
|
|
217
|
+
slk deactivations 90d # Everyone who left in the last 90 days
|
|
218
|
+
slk deactivations 2026-01-01 -n 0 # All departures this year
|
|
219
|
+
slk deactivations --chart # Departures per month
|
|
220
|
+
slk deactivations --grep engineer # Filter by name, handle, title, email, or ID
|
|
221
|
+
slk deactivations --bots # Include deactivated bots and app users
|
|
222
|
+
slk deactivations --json # Machine-readable output
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
acme: 16 deactivated since 30d (664 active members)
|
|
227
|
+
|
|
228
|
+
2026-09-14 Dana Whitfield Platform Support
|
|
229
|
+
2026-09-11 Priya Raghunathan UX Researcher
|
|
230
|
+
2026-09-09 Sam Okonkwo Senior Software Engineer
|
|
231
|
+
|
|
232
|
+
roster cached 5m ago; --refresh to update
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Dates come from each account's `updated` field — the last change Slack recorded
|
|
236
|
+
for that user. For a deactivated account that change is almost always the
|
|
237
|
+
deactivation, but an admin editing a departed profile afterwards moves the date
|
|
238
|
+
forward, so treat it as "last touched" rather than a payroll record.
|
|
239
|
+
|
|
240
|
+
The roster costs one API call per 1000 members, so the result is cached for six
|
|
241
|
+
hours; `--refresh` re-fetches it.
|
|
242
|
+
|
|
213
243
|
### Global Options
|
|
214
244
|
|
|
215
245
|
```bash
|
data/lib/slk/api/users.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Slk
|
|
4
4
|
module Api
|
|
5
5
|
# Wrapper for Slack users.* API endpoints
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
6
7
|
class Users
|
|
7
8
|
def initialize(api_client, workspace, on_debug: nil)
|
|
8
9
|
@api = api_client
|
|
@@ -63,6 +64,31 @@ module Slk
|
|
|
63
64
|
@api.post(@workspace, 'users.list', params)
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
# Page through users.list until the cursor runs out. Yields the running
|
|
68
|
+
# total after each page, for progress and debug output.
|
|
69
|
+
def list_all(limit: 1000, &progress)
|
|
70
|
+
members = []
|
|
71
|
+
cursor = nil
|
|
72
|
+
loop do
|
|
73
|
+
response = list(cursor: cursor, limit: limit)
|
|
74
|
+
members.concat(response['members'] || [])
|
|
75
|
+
progress&.call(members.size)
|
|
76
|
+
cursor = next_cursor(response, cursor)
|
|
77
|
+
break members unless cursor
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Slack ends the roster with an empty cursor. A cursor that comes back
|
|
82
|
+
# unchanged never will, and paging on it spins forever against a remote
|
|
83
|
+
# API — better to fail with the reason than to hang holding a terminal.
|
|
84
|
+
def next_cursor(response, previous)
|
|
85
|
+
cursor = response.dig('response_metadata', 'next_cursor').to_s
|
|
86
|
+
return nil if cursor.empty?
|
|
87
|
+
raise ApiError.new('users.list returned a repeating cursor', code: :invalid_cursor) if cursor == previous
|
|
88
|
+
|
|
89
|
+
cursor
|
|
90
|
+
end
|
|
91
|
+
|
|
66
92
|
def info(user_id)
|
|
67
93
|
@api.post_form(@workspace, 'users.info', { user: user_id })
|
|
68
94
|
end
|
|
@@ -111,5 +137,6 @@ module Slk
|
|
|
111
137
|
@api.post_form(@workspace, 'users.conversations', params)
|
|
112
138
|
end
|
|
113
139
|
end
|
|
140
|
+
# rubocop:enable Metrics/ClassLength
|
|
114
141
|
end
|
|
115
142
|
end
|
data/lib/slk/cli.rb
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Commands
|
|
5
|
+
# Show who left the workspace and when, derived from users.list.
|
|
6
|
+
# Examples:
|
|
7
|
+
# slk deactivations # 25 most recent departures
|
|
8
|
+
# slk deactivations 90d # everyone who left in the last 90 days
|
|
9
|
+
# slk deactivations --chart # departures per month
|
|
10
|
+
# slk deactivations --grep engineer # filter by name, handle, title, email, ID
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
12
|
+
class Deactivations < Base
|
|
13
|
+
DEFAULT_LIMIT = 25
|
|
14
|
+
CHART_MONTHS = 12
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
result = validate_options
|
|
18
|
+
return result if result
|
|
19
|
+
|
|
20
|
+
run
|
|
21
|
+
rescue ApiError => e
|
|
22
|
+
error("API error: #{e.message}")
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
protected
|
|
27
|
+
|
|
28
|
+
def handle_option(arg, args, _remaining)
|
|
29
|
+
case arg
|
|
30
|
+
when '-n', '--limit' then @options[:limit] = parse_limit(arg, option_value(arg, args))
|
|
31
|
+
when '--since' then @options[:since] = option_value(arg, args)
|
|
32
|
+
when '--chart' then @options[:chart] = true
|
|
33
|
+
when '--bots' then @options[:bots] = true
|
|
34
|
+
when '--grep' then @options[:grep] = option_value(arg, args)
|
|
35
|
+
when '--refresh', '--no-cache' then @options[:refresh] = true
|
|
36
|
+
else return super
|
|
37
|
+
end
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def help_text
|
|
42
|
+
help = Support::HelpFormatter.new('slk deactivations [since] [options]')
|
|
43
|
+
help.description('Show deactivated accounts — who left the workspace, and when.')
|
|
44
|
+
help.note("Dates come from each account's `updated` field, which for a deactivated")
|
|
45
|
+
help.note('account is the deactivation itself unless an admin edited the profile after.')
|
|
46
|
+
add_options_section(help)
|
|
47
|
+
add_examples_section(help)
|
|
48
|
+
help.render
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Base defaults to 72 columns for prose wrapping; this is a table, so use
|
|
54
|
+
# the whole terminal and let long titles keep their tail. A tty that
|
|
55
|
+
# refuses to report its size (some Windows consoles, some CI shims) is
|
|
56
|
+
# not a reason to fail before the command has even parsed its arguments.
|
|
57
|
+
def default_width
|
|
58
|
+
return 100 unless $stdout.tty?
|
|
59
|
+
|
|
60
|
+
IO.console&.winsize&.last || 100
|
|
61
|
+
rescue Errno::ENOTTY, Errno::EINVAL, Errno::ENODEV, IOError, NotImplementedError
|
|
62
|
+
100
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `-n foo` used to reach to_i, become 0, and quietly mean "no limit" —
|
|
66
|
+
# the opposite of asking for fewer rows.
|
|
67
|
+
def parse_limit(flag, value)
|
|
68
|
+
limit = Integer(value, exception: false)
|
|
69
|
+
return limit if limit && !limit.negative?
|
|
70
|
+
|
|
71
|
+
raise UsageError, "#{flag} expects a non-negative integer (got #{value.inspect})."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_options_section(help)
|
|
75
|
+
help.section('OPTIONS') do |s|
|
|
76
|
+
s.option('-n, --limit N', "Rows to show (default #{DEFAULT_LIMIT}, 0 for all)")
|
|
77
|
+
s.option('--since SPEC', 'Only departures since 7d, 4w, 6m, or YYYY-MM-DD')
|
|
78
|
+
s.option('--chart', 'Histogram of departures per month')
|
|
79
|
+
s.option('--grep PATTERN', 'Filter by name, handle, title, email, or user ID')
|
|
80
|
+
s.option('--bots', 'Include deactivated bots and app users')
|
|
81
|
+
s.option('--refresh', 'Re-fetch the roster instead of using the cache')
|
|
82
|
+
s.option('--json', 'Raw JSON output')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def add_examples_section(help)
|
|
87
|
+
help.section('EXAMPLES') do |s|
|
|
88
|
+
s.example('slk deactivations', 'Most recent departures')
|
|
89
|
+
s.example('slk deactivations 90d', 'Everyone who left in the last 90 days')
|
|
90
|
+
s.example('slk deactivations --chart', 'Departures per month')
|
|
91
|
+
s.example('slk deactivations 2026-01-01 -n 0', 'All departures this year')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def run
|
|
96
|
+
workspace = runner.workspace(@options[:workspace])
|
|
97
|
+
@since_label = since_spec
|
|
98
|
+
@since = parse_since(@since_label)
|
|
99
|
+
report = scan(workspace)
|
|
100
|
+
records = collect_records(report)
|
|
101
|
+
|
|
102
|
+
return render_json(workspace, report, records) if @options[:json]
|
|
103
|
+
|
|
104
|
+
render(workspace, report, records)
|
|
105
|
+
0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One window, or none. A second date is a different question, and
|
|
109
|
+
# answering the first one silently is how you misread the answer.
|
|
110
|
+
def since_spec
|
|
111
|
+
extra = positional_args[1..]
|
|
112
|
+
raise UsageError, "Unexpected argument: #{extra.first}. Only one time window is accepted." if extra&.any?
|
|
113
|
+
|
|
114
|
+
@options[:since] || positional_args.first
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def collect_records(report)
|
|
118
|
+
records = filter(report.records)
|
|
119
|
+
@options[:chart] && @since.nil? ? last_year(records) : records
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def render_json(workspace, report, records)
|
|
123
|
+
output_json(json_payload(workspace, report, records))
|
|
124
|
+
0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def scan(workspace)
|
|
128
|
+
Services::DeactivationScanner.new(
|
|
129
|
+
users_api: runner.users_api(workspace.name),
|
|
130
|
+
workspace_name: workspace.name,
|
|
131
|
+
cache_store: cache_store,
|
|
132
|
+
on_debug: ->(msg) { output.debug(msg) }
|
|
133
|
+
).scan(refresh: @options[:refresh])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Filters compose: --bots, --since, --grep all narrow the same list.
|
|
137
|
+
# Records Slack never dated cannot answer a question about a window, so
|
|
138
|
+
# they drop out of one — but they are counted, not silently discarded.
|
|
139
|
+
def filter(records)
|
|
140
|
+
records = records.reject(&:bot) unless @options[:bots]
|
|
141
|
+
pattern = grep_pattern
|
|
142
|
+
records = records.select { |r| r.matches?(pattern) } if pattern
|
|
143
|
+
@undated = records.count { |r| r.deactivated_at.nil? }
|
|
144
|
+
reject_before(records, @since)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def reject_before(records, cutoff)
|
|
148
|
+
return records unless cutoff
|
|
149
|
+
|
|
150
|
+
records.select { |r| r.deactivated_at && r.deactivated_at >= cutoff }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# An all-time histogram of a decade-old workspace is mostly scrollback.
|
|
154
|
+
# Counting in months rather than in 31-day steps keeps the window exactly
|
|
155
|
+
# as long as the label claims.
|
|
156
|
+
def last_year(records)
|
|
157
|
+
reject_before(records, last_year_cutoff)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def last_year_cutoff
|
|
161
|
+
now = Time.now
|
|
162
|
+
index = (now.year * 12) + (now.month - 1) - (CHART_MONTHS - 1)
|
|
163
|
+
Time.new(index / 12, (index % 12) + 1, 1).to_i
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The chart spans the window that was asked for, not merely the months
|
|
167
|
+
# that happen to contain a departure: a quiet opening month is the
|
|
168
|
+
# answer to "how bad is it lately", and dropping it flatters the trend.
|
|
169
|
+
def chart_bounds
|
|
170
|
+
{
|
|
171
|
+
from: Time.at(@since || last_year_cutoff).strftime('%Y-%m'),
|
|
172
|
+
to: Time.now.strftime('%Y-%m')
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_since(spec)
|
|
177
|
+
return nil unless spec
|
|
178
|
+
|
|
179
|
+
Support::DateParser.parse(spec)
|
|
180
|
+
rescue ArgumentError => e
|
|
181
|
+
raise UsageError, e.message
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def grep_pattern
|
|
185
|
+
return nil unless @options[:grep]
|
|
186
|
+
|
|
187
|
+
Regexp.new(@options[:grep], Regexp::IGNORECASE)
|
|
188
|
+
rescue RegexpError => e
|
|
189
|
+
raise UsageError, "Invalid --grep pattern: #{e.message}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def render(workspace, report, records)
|
|
193
|
+
formatter = Formatters::DeactivationFormatter.new(output: output, width: @options[:width])
|
|
194
|
+
formatter.summary(summary_line(workspace, report, records))
|
|
195
|
+
render_body(formatter, records)
|
|
196
|
+
footer = footer(report, records)
|
|
197
|
+
return if footer.empty?
|
|
198
|
+
|
|
199
|
+
puts
|
|
200
|
+
formatter.note(footer)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Even an empty result keeps its footer: "nobody matched" is worth much
|
|
204
|
+
# less without how old the roster behind it is.
|
|
205
|
+
def render_body(formatter, records)
|
|
206
|
+
return info('No deactivations match.') if records.empty?
|
|
207
|
+
|
|
208
|
+
puts
|
|
209
|
+
@options[:chart] ? formatter.chart(records, **chart_bounds) : render_list(formatter, records)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def render_list(formatter, records)
|
|
213
|
+
limit = @options[:limit] || DEFAULT_LIMIT
|
|
214
|
+
shown = limit.positive? ? records.first(limit) : records
|
|
215
|
+
formatter.list(shown)
|
|
216
|
+
return unless shown.size < records.size
|
|
217
|
+
|
|
218
|
+
puts
|
|
219
|
+
formatter.note("Showing #{shown.size} of #{records.size} — use -n 0 to see them all.")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def summary_line(workspace, report, records)
|
|
223
|
+
"#{workspace.name}: #{records.size} #{scope_phrase} " \
|
|
224
|
+
"(#{report.active_count} active members)"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def scope_phrase
|
|
228
|
+
return "deactivated since #{@since_label}" if @since_label
|
|
229
|
+
|
|
230
|
+
@options[:chart] ? "deactivated in the last #{CHART_MONTHS} months" : 'deactivated accounts'
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def footer(report, records)
|
|
234
|
+
total = total_deactivated(report)
|
|
235
|
+
age = cache_age(report)
|
|
236
|
+
parts = []
|
|
237
|
+
parts << "#{total} deactivated in all (of #{report.human_count} accounts ever created)" if records.size < total
|
|
238
|
+
parts << undated_note if undated_note
|
|
239
|
+
parts << "roster cached #{age} ago; --refresh to update" if age
|
|
240
|
+
parts.join(' · ')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Only worth saying when a window was applied: without one nothing was
|
|
244
|
+
# dropped for want of a date.
|
|
245
|
+
def undated_note
|
|
246
|
+
return nil unless windowed? && @undated.to_i.positive?
|
|
247
|
+
|
|
248
|
+
"#{@undated} with no recorded date omitted"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def windowed?
|
|
252
|
+
!@since.nil? || @options[:chart]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def total_deactivated(report)
|
|
256
|
+
return report.deactivated_count if @options[:bots]
|
|
257
|
+
|
|
258
|
+
report.records.count { |r| !r.bot }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def cache_age(report)
|
|
262
|
+
return nil unless report.fetched_at
|
|
263
|
+
|
|
264
|
+
seconds = Time.now.to_i - report.fetched_at
|
|
265
|
+
return nil if seconds < 60
|
|
266
|
+
|
|
267
|
+
Models::Duration.new(seconds: seconds).to_s
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def json_payload(workspace, report, records)
|
|
271
|
+
{
|
|
272
|
+
workspace: workspace.name,
|
|
273
|
+
fetched_at: report.fetched_at,
|
|
274
|
+
active_members: report.active_count,
|
|
275
|
+
accounts_ever: report.human_count,
|
|
276
|
+
total_deactivated: total_deactivated(report),
|
|
277
|
+
includes_bots: @options[:bots] ? true : false,
|
|
278
|
+
matched: records.size,
|
|
279
|
+
deactivations: records.map { |r| r.to_h.merge(deactivated_on: r.date) }
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
# rubocop:enable Metrics/ClassLength
|
|
284
|
+
end
|
|
285
|
+
end
|
data/lib/slk/commands/help.rb
CHANGED
|
@@ -4,6 +4,26 @@ module Slk
|
|
|
4
4
|
module Commands
|
|
5
5
|
# Displays help information for commands
|
|
6
6
|
class Help < Base
|
|
7
|
+
# Names are padded to the longest one rather than by hand, so adding a
|
|
8
|
+
# command cannot quietly break the column for every line below it.
|
|
9
|
+
COMMAND_SUMMARIES = [
|
|
10
|
+
['status', 'Get or set your status'],
|
|
11
|
+
['presence', 'Get or set your presence (away/active)'],
|
|
12
|
+
['dnd', 'Manage Do Not Disturb'],
|
|
13
|
+
['messages', 'Read channel or DM messages'],
|
|
14
|
+
['search', 'Search messages across channels'],
|
|
15
|
+
['unread', 'View and clear unread messages'],
|
|
16
|
+
['activity', 'Show activity feed (reactions, mentions, threads)'],
|
|
17
|
+
['later', 'Show saved "Later" items'],
|
|
18
|
+
['who', 'Show a user profile'],
|
|
19
|
+
['deactivations', 'Show who left the workspace, and when'],
|
|
20
|
+
['preset', 'Manage and apply status presets'],
|
|
21
|
+
['workspaces', 'Manage Slack workspaces'],
|
|
22
|
+
['cache', 'Manage user/channel cache'],
|
|
23
|
+
['emoji', 'Download workspace custom emoji'],
|
|
24
|
+
['config', 'Configuration and setup']
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
7
27
|
def execute
|
|
8
28
|
topic = positional_args.first
|
|
9
29
|
|
|
@@ -35,26 +55,13 @@ module Slk
|
|
|
35
55
|
HEADER
|
|
36
56
|
end
|
|
37
57
|
|
|
38
|
-
# rubocop:disable Metrics/AbcSize
|
|
39
58
|
def build_commands_section
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
#{output.cyan('messages')} Read channel or DM messages
|
|
46
|
-
#{output.cyan('search')} Search messages across channels
|
|
47
|
-
#{output.cyan('unread')} View and clear unread messages
|
|
48
|
-
#{output.cyan('activity')} Show activity feed (reactions, mentions, threads)
|
|
49
|
-
#{output.cyan('later')} Show saved "Later" items
|
|
50
|
-
#{output.cyan('preset')} Manage and apply status presets
|
|
51
|
-
#{output.cyan('workspaces')} Manage Slack workspaces
|
|
52
|
-
#{output.cyan('cache')} Manage user/channel cache
|
|
53
|
-
#{output.cyan('emoji')} Download workspace custom emoji
|
|
54
|
-
#{output.cyan('config')} Configuration and setup
|
|
55
|
-
COMMANDS
|
|
59
|
+
width = COMMAND_SUMMARIES.map { |name, _| name.length }.max + 2
|
|
60
|
+
rows = COMMAND_SUMMARIES.map do |name, summary|
|
|
61
|
+
" #{output.cyan(name)}#{' ' * (width - name.length)}#{summary}"
|
|
62
|
+
end
|
|
63
|
+
"#{output.bold('COMMANDS:')}\n#{rows.join("\n")}\n"
|
|
56
64
|
end
|
|
57
|
-
# rubocop:enable Metrics/AbcSize
|
|
58
65
|
|
|
59
66
|
def build_options_section
|
|
60
67
|
<<~OPTIONS
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders deactivation lists and per-month histograms.
|
|
6
|
+
class DeactivationFormatter
|
|
7
|
+
DATE_WIDTH = 10
|
|
8
|
+
MAX_NAME_WIDTH = 26
|
|
9
|
+
MIN_TITLE_WIDTH = 12
|
|
10
|
+
MAX_BAR_WIDTH = 48
|
|
11
|
+
BAR_CHARS = '█'
|
|
12
|
+
|
|
13
|
+
def initialize(output:, width: nil)
|
|
14
|
+
@output = output
|
|
15
|
+
@width = width || 100
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# One line per departure: date, name, title. Full ISO dates on every row
|
|
19
|
+
# (rather than month headings) so the output stays greppable.
|
|
20
|
+
def list(records)
|
|
21
|
+
name_width = name_column_width(records)
|
|
22
|
+
records.each { |record| @output.puts(row(record, name_width)) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def chart(records, from: nil, to: nil)
|
|
26
|
+
counts = monthly_counts(records, from: from, to: to)
|
|
27
|
+
return if counts.empty?
|
|
28
|
+
|
|
29
|
+
peak = counts.values.max
|
|
30
|
+
label_width = counts.values.map { |n| n.to_s.length }.max
|
|
31
|
+
counts.each { |month, count| @output.puts(chart_row(month, count, peak, label_width)) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summary(text)
|
|
35
|
+
@output.puts(@output.bold(text))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def note(text)
|
|
39
|
+
@output.puts(@output.gray(text))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Deactivations per calendar month, oldest first, with empty months
|
|
43
|
+
# filled in — a gap in a histogram should read as zero, not as absence.
|
|
44
|
+
# `from`/`to` widen the span to the window the caller asked about, so a
|
|
45
|
+
# quiet first or last month is shown as quiet rather than dropped.
|
|
46
|
+
def monthly_counts(records, from: nil, to: nil)
|
|
47
|
+
months = records.filter_map(&:month).sort
|
|
48
|
+
first = [from, months.first].compact.min
|
|
49
|
+
last = [to, months.last].compact.max
|
|
50
|
+
return {} if first.nil? || last.nil? || first > last
|
|
51
|
+
|
|
52
|
+
all_months(first, last).to_h do |month|
|
|
53
|
+
[month, months.count(month)]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def chart_row(month, count, peak, label_width)
|
|
60
|
+
bar = BAR_CHARS * bar_length(count, peak)
|
|
61
|
+
"#{@output.gray(month)} #{count.to_s.rjust(label_width)} #{bar}".rstrip
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def row(record, name_width)
|
|
65
|
+
date = record.date || 'unknown'
|
|
66
|
+
name = truncate(record.best_name.to_s, name_width).ljust(name_width)
|
|
67
|
+
title = truncate(record.title.to_s, title_width(name_width))
|
|
68
|
+
line = "#{@output.gray(date.ljust(DATE_WIDTH))} #{name}"
|
|
69
|
+
title.empty? ? line : "#{line} #{@output.gray(title)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def name_column_width(records)
|
|
73
|
+
longest = records.map { |r| r.best_name.to_s.length }.max || 0
|
|
74
|
+
longest.clamp(8, MAX_NAME_WIDTH)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def title_width(name_width)
|
|
78
|
+
[@width - DATE_WIDTH - name_width - 4, MIN_TITLE_WIDTH].max
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A month nobody left gets no bar at all. Rounding a zero up to one
|
|
82
|
+
# block would draw departures that did not happen.
|
|
83
|
+
def bar_length(count, peak)
|
|
84
|
+
return 0 if peak.zero? || count.zero?
|
|
85
|
+
|
|
86
|
+
available = (@width - 16).clamp(10, MAX_BAR_WIDTH)
|
|
87
|
+
[((count.to_f / peak) * available).round, 1].max
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def truncate(text, width)
|
|
91
|
+
return text if text.length <= width
|
|
92
|
+
|
|
93
|
+
"#{text[0, width - 1]}…"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def all_months(first, last)
|
|
97
|
+
(month_index(first)..month_index(last)).map do |index|
|
|
98
|
+
format('%<year>04d-%<month>02d', year: index / 12, month: (index % 12) + 1)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def month_index(month)
|
|
103
|
+
year, mon = month.split('-').map(&:to_i)
|
|
104
|
+
(year * 12) + (mon - 1)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# A deactivated Slack account and when it was deactivated.
|
|
6
|
+
#
|
|
7
|
+
# `deactivated_at` comes from the user object's `updated` field, which is
|
|
8
|
+
# the epoch of the last change to that account. For a deactivated account
|
|
9
|
+
# the deactivation is almost always that last change — but an admin who
|
|
10
|
+
# edits a departed user's profile afterwards moves the timestamp forward,
|
|
11
|
+
# so treat it as "last touched", not a payroll record.
|
|
12
|
+
Deactivation = Data.define(
|
|
13
|
+
:user_id, :handle, :real_name, :title, :email, :deactivated_at, :bot
|
|
14
|
+
) do
|
|
15
|
+
def self.from_api(member)
|
|
16
|
+
profile = member['profile'] || {}
|
|
17
|
+
|
|
18
|
+
new(
|
|
19
|
+
user_id: member['id'],
|
|
20
|
+
handle: member['name'],
|
|
21
|
+
real_name: profile['real_name'] || member['real_name'],
|
|
22
|
+
title: profile['title'],
|
|
23
|
+
email: profile['email'],
|
|
24
|
+
deactivated_at: positive_int(member['updated']),
|
|
25
|
+
bot: bot?(member)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Rebuild from a JSON round-trip through the meta cache.
|
|
30
|
+
def self.from_cache(hash)
|
|
31
|
+
new(
|
|
32
|
+
user_id: hash['user_id'], handle: hash['handle'], real_name: hash['real_name'],
|
|
33
|
+
title: hash['title'], email: hash['email'],
|
|
34
|
+
deactivated_at: positive_int(hash['deactivated_at']), bot: hash['bot'] ? true : false
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.bot?(member)
|
|
39
|
+
member['is_bot'] || member['is_app_user'] || member['id'] == 'USLACKBOT' ? true : false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.positive_int(value)
|
|
43
|
+
int = value.to_i
|
|
44
|
+
int.positive? ? int : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def best_name
|
|
48
|
+
return real_name unless real_name.to_s.empty?
|
|
49
|
+
return handle unless handle.to_s.empty?
|
|
50
|
+
|
|
51
|
+
user_id.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def deactivated_time
|
|
55
|
+
deactivated_at && Time.at(deactivated_at)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def date
|
|
59
|
+
deactivated_time&.strftime('%Y-%m-%d')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def month
|
|
63
|
+
deactivated_time&.strftime('%Y-%m')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Matches the caller's pattern against every field a human would search
|
|
67
|
+
# by. Case sensitivity is the pattern's to declare, not this method's.
|
|
68
|
+
def matches?(pattern)
|
|
69
|
+
[handle, real_name, title, email, user_id].compact.any? { |field| pattern.match?(field) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_cache
|
|
73
|
+
to_h.transform_keys(&:to_s)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Slack has no "who left" endpoint. What it does have is users.list, which
|
|
6
|
+
# returns deactivated accounts with `deleted: true` and an `updated` epoch
|
|
7
|
+
# for the last change to the account — and for a deactivated account that
|
|
8
|
+
# change is nearly always the deactivation itself.
|
|
9
|
+
#
|
|
10
|
+
# The full roster is one API call per 1000 members, so the derived result
|
|
11
|
+
# (deactivated members plus headcounts, not the whole roster) is cached in
|
|
12
|
+
# the workspace meta cache with a TTL.
|
|
13
|
+
class DeactivationScanner
|
|
14
|
+
CACHE_KEY = 'deactivations_v1'
|
|
15
|
+
DEFAULT_TTL = 21_600 # 6 hours
|
|
16
|
+
REQUIRED_KEYS = %w[fetched_at member_count human_count active_count records].freeze
|
|
17
|
+
|
|
18
|
+
Report = Data.define(:records, :member_count, :human_count, :active_count, :fetched_at) do
|
|
19
|
+
def deactivated_count = records.size
|
|
20
|
+
|
|
21
|
+
def bots = records.count(&:bot)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(users_api:, workspace_name:, cache_store: nil, ttl: DEFAULT_TTL, on_debug: nil)
|
|
25
|
+
@users_api = users_api
|
|
26
|
+
@workspace_name = workspace_name
|
|
27
|
+
@cache = cache_store
|
|
28
|
+
@ttl = ttl
|
|
29
|
+
@on_debug = on_debug
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Report] every deactivated account, newest deactivation first
|
|
33
|
+
def scan(refresh: false)
|
|
34
|
+
build_report(cached(refresh: refresh) || store(collect))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def cached(refresh:)
|
|
40
|
+
return nil if refresh
|
|
41
|
+
|
|
42
|
+
data = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: @ttl)
|
|
43
|
+
return data if usable?(data)
|
|
44
|
+
|
|
45
|
+
@on_debug&.call('deactivations cache is unusable; re-fetching the roster') if data
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A truncated or older-shaped entry would otherwise be read field by
|
|
50
|
+
# field into a report of zero active members and zero departures — a
|
|
51
|
+
# confident, wrong answer. Missing anything required means refetch.
|
|
52
|
+
def usable?(data)
|
|
53
|
+
data.is_a?(Hash) && data['records'].is_a?(Array) && REQUIRED_KEYS.all? { |key| data[key] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def store(data)
|
|
57
|
+
MetaCache.write(@cache, @workspace_name, CACHE_KEY, data)
|
|
58
|
+
data
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def collect
|
|
62
|
+
members = fetch_members
|
|
63
|
+
deleted = members.select { |m| m['deleted'] }
|
|
64
|
+
|
|
65
|
+
counts(members).merge(
|
|
66
|
+
'fetched_at' => Time.now.to_i,
|
|
67
|
+
'records' => deleted.map { |m| Models::Deactivation.from_api(m).to_cache }
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def counts(members)
|
|
72
|
+
humans = members.reject { |m| Models::Deactivation.bot?(m) }
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
'member_count' => members.size,
|
|
76
|
+
'human_count' => humans.size,
|
|
77
|
+
'active_count' => humans.count { |m| !m['deleted'] }
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fetch_members
|
|
82
|
+
@users_api.list_all do |total|
|
|
83
|
+
@on_debug&.call("users.list: #{total} members fetched")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_report(data)
|
|
88
|
+
Report.new(
|
|
89
|
+
records: sorted_records(data['records'] || []),
|
|
90
|
+
member_count: data['member_count'].to_i,
|
|
91
|
+
human_count: data['human_count'].to_i,
|
|
92
|
+
active_count: data['active_count'].to_i,
|
|
93
|
+
fetched_at: data['fetched_at']&.to_i
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Newest first; accounts with no usable timestamp sort to the bottom
|
|
98
|
+
# rather than pretending to be from 1970.
|
|
99
|
+
def sorted_records(raw)
|
|
100
|
+
raw.map { |hash| Models::Deactivation.from_cache(hash) }
|
|
101
|
+
.sort_by { |record| -(record.deactivated_at || 0) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -74,6 +74,7 @@ module Slk
|
|
|
74
74
|
autoload :User, 'slk/models/user'
|
|
75
75
|
autoload :Channel, 'slk/models/channel'
|
|
76
76
|
autoload :Preset, 'slk/models/preset'
|
|
77
|
+
autoload :Deactivation, 'slk/models/deactivation'
|
|
77
78
|
autoload :SearchResult, 'slk/models/search_result'
|
|
78
79
|
autoload :SavedItem, 'slk/models/saved_item'
|
|
79
80
|
autoload :Profile, 'slk/models/profile'
|
|
@@ -107,6 +108,7 @@ module Slk
|
|
|
107
108
|
autoload :ProfileBuilder, 'slk/services/profile_builder'
|
|
108
109
|
autoload :ProfileResolver, 'slk/services/profile_resolver'
|
|
109
110
|
autoload :MetaCache, 'slk/services/meta_cache'
|
|
111
|
+
autoload :DeactivationScanner, 'slk/services/deactivation_scanner'
|
|
110
112
|
end
|
|
111
113
|
|
|
112
114
|
# Output formatters for messages, durations, and emoji
|
|
@@ -129,6 +131,7 @@ module Slk
|
|
|
129
131
|
autoload :ProfileFormatter, 'slk/formatters/profile_formatter'
|
|
130
132
|
autoload :ProfileFieldRenderer, 'slk/formatters/profile_field_renderer'
|
|
131
133
|
autoload :ProfileRows, 'slk/formatters/profile_rows'
|
|
134
|
+
autoload :DeactivationFormatter, 'slk/formatters/deactivation_formatter'
|
|
132
135
|
end
|
|
133
136
|
|
|
134
137
|
# CLI commands implementing user-facing functionality
|
|
@@ -153,6 +156,7 @@ module Slk
|
|
|
153
156
|
autoload :Debug, 'slk/commands/debug'
|
|
154
157
|
autoload :Who, 'slk/commands/who'
|
|
155
158
|
autoload :Org, 'slk/commands/org'
|
|
159
|
+
autoload :Deactivations, 'slk/commands/deactivations'
|
|
156
160
|
end
|
|
157
161
|
|
|
158
162
|
# Thin wrappers around Slack API endpoints
|
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.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -45,6 +45,7 @@ files:
|
|
|
45
45
|
- lib/slk/commands/cache.rb
|
|
46
46
|
- lib/slk/commands/catchup.rb
|
|
47
47
|
- lib/slk/commands/config.rb
|
|
48
|
+
- lib/slk/commands/deactivations.rb
|
|
48
49
|
- lib/slk/commands/debug.rb
|
|
49
50
|
- lib/slk/commands/dnd.rb
|
|
50
51
|
- lib/slk/commands/emoji.rb
|
|
@@ -64,6 +65,7 @@ files:
|
|
|
64
65
|
- lib/slk/formatters/activity_formatter.rb
|
|
65
66
|
- lib/slk/formatters/attachment_formatter.rb
|
|
66
67
|
- lib/slk/formatters/block_formatter.rb
|
|
68
|
+
- lib/slk/formatters/deactivation_formatter.rb
|
|
67
69
|
- lib/slk/formatters/duration_formatter.rb
|
|
68
70
|
- lib/slk/formatters/emoji_replacer.rb
|
|
69
71
|
- lib/slk/formatters/json_message_formatter.rb
|
|
@@ -80,6 +82,7 @@ files:
|
|
|
80
82
|
- lib/slk/formatters/search_formatter.rb
|
|
81
83
|
- lib/slk/formatters/text_processor.rb
|
|
82
84
|
- lib/slk/models/channel.rb
|
|
85
|
+
- lib/slk/models/deactivation.rb
|
|
83
86
|
- lib/slk/models/dnd_state.rb
|
|
84
87
|
- lib/slk/models/duration.rb
|
|
85
88
|
- lib/slk/models/message.rb
|
|
@@ -99,6 +102,7 @@ files:
|
|
|
99
102
|
- lib/slk/services/api_client.rb
|
|
100
103
|
- lib/slk/services/cache_store.rb
|
|
101
104
|
- lib/slk/services/configuration.rb
|
|
105
|
+
- lib/slk/services/deactivation_scanner.rb
|
|
102
106
|
- lib/slk/services/emoji_downloader.rb
|
|
103
107
|
- lib/slk/services/emoji_searcher.rb
|
|
104
108
|
- lib/slk/services/encryption.rb
|