slk 0.8.0 → 0.10.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 +36 -0
- data/README.md +67 -0
- data/lib/slk/api/users.rb +27 -0
- data/lib/slk/cli.rb +2 -1
- data/lib/slk/commands/cache.rb +10 -9
- data/lib/slk/commands/deactivations.rb +415 -0
- data/lib/slk/commands/help.rb +25 -18
- data/lib/slk/formatters/csv_writer.rb +34 -0
- data/lib/slk/formatters/deactivation_csv.rb +49 -0
- data/lib/slk/formatters/deactivation_formatter.rb +124 -0
- data/lib/slk/formatters/output.rb +39 -0
- data/lib/slk/models/deactivation.rb +77 -0
- data/lib/slk/models/tenure.rb +66 -0
- data/lib/slk/services/api_client.rb +12 -1
- data/lib/slk/services/cache_store.rb +19 -0
- data/lib/slk/services/deactivation_scanner.rb +105 -0
- data/lib/slk/services/meta_cache.rb +16 -1
- data/lib/slk/services/start_date_field.rb +67 -0
- data/lib/slk/services/start_date_lookup.rb +98 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +10 -0
- metadata +10 -1
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,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# RFC 4180 CSV, hand-rolled: Ruby 3.4 moved csv out of the default gems
|
|
6
|
+
# and into the bundled ones, so requiring it would make this tool depend
|
|
7
|
+
# on a gem, and it ships with none.
|
|
8
|
+
#
|
|
9
|
+
# Quotes only the fields that need it, so the common row stays readable in
|
|
10
|
+
# a terminal as well as in a spreadsheet.
|
|
11
|
+
module CsvWriter
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
NEEDS_QUOTES = /[",\r\n]|\A\s|\s\z/
|
|
15
|
+
|
|
16
|
+
def row(values)
|
|
17
|
+
values.map { |value| escape(value) }.join(',')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def escape(value)
|
|
21
|
+
text = stringify(value)
|
|
22
|
+
return text unless NEEDS_QUOTES.match?(text)
|
|
23
|
+
|
|
24
|
+
%("#{text.gsub('"', '""')}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# nil is an empty cell. Writing the literal "nil" would give a
|
|
28
|
+
# spreadsheet a four-character string to count, sort and average.
|
|
29
|
+
def stringify(value)
|
|
30
|
+
value.nil? ? '' : value.to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# CSV export of a deactivation list, for the spreadsheet that inevitably
|
|
6
|
+
# gets asked for. Every matched row is written, not just the screenful a
|
|
7
|
+
# terminal would show — a truncated export is a wrong answer that looks
|
|
8
|
+
# like a right one.
|
|
9
|
+
class DeactivationCsv
|
|
10
|
+
HEADERS = %w[deactivated_on user_id handle real_name title email bot].freeze
|
|
11
|
+
TENURE_HEADERS = %w[started_on tenure_months tenure].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(output:, tenures: nil)
|
|
14
|
+
@output = output
|
|
15
|
+
@tenures = tenures
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render(records)
|
|
19
|
+
@output.puts(CsvWriter.row(headers))
|
|
20
|
+
records.each { |record| @output.puts(CsvWriter.row(cells(record))) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def headers
|
|
26
|
+
@tenures ? HEADERS + TENURE_HEADERS : HEADERS
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cells(record)
|
|
30
|
+
row = [record.date, record.user_id, record.handle, record.real_name,
|
|
31
|
+
record.title, record.email, record.bot]
|
|
32
|
+
@tenures ? row + tenure_cells(record) : row
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Empty cells rather than zeros, so a spreadsheet averaging tenure skips
|
|
36
|
+
# them instead of counting people who left the day they arrived. Two
|
|
37
|
+
# different unknowns land here: no start date on file at all, which
|
|
38
|
+
# blanks the whole group, and a start date later than the departure,
|
|
39
|
+
# which keeps started_on so the bad data is visible and blanks the
|
|
40
|
+
# length that cannot be derived from it.
|
|
41
|
+
def tenure_cells(record)
|
|
42
|
+
tenure = @tenures[record.user_id]
|
|
43
|
+
return [nil, nil, nil] unless tenure
|
|
44
|
+
|
|
45
|
+
[tenure.started, tenure.months, tenure.to_s]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
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, tenure, title. Full ISO dates on
|
|
19
|
+
# every row (rather than month headings) so the output stays greppable.
|
|
20
|
+
#
|
|
21
|
+
# `tenures` is keyed by user ID and may cover only some of the rows: a
|
|
22
|
+
# start date nobody filled in leaves the column blank rather than
|
|
23
|
+
# guessing, and the column disappears entirely when none are known.
|
|
24
|
+
def list(records, tenures: {})
|
|
25
|
+
name_width = name_column_width(records)
|
|
26
|
+
tenure_width = tenure_column_width(records, tenures)
|
|
27
|
+
records.each { |record| @output.puts(row(record, name_width, tenures, tenure_width)) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def chart(records, from: nil, to: nil)
|
|
31
|
+
counts = monthly_counts(records, from: from, to: to)
|
|
32
|
+
return if counts.empty?
|
|
33
|
+
|
|
34
|
+
peak = counts.values.max
|
|
35
|
+
label_width = counts.values.map { |n| n.to_s.length }.max
|
|
36
|
+
counts.each { |month, count| @output.puts(chart_row(month, count, peak, label_width)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def summary(text)
|
|
40
|
+
@output.puts(@output.bold(text))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def note(text)
|
|
44
|
+
@output.puts(@output.gray(text))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Deactivations per calendar month, oldest first, with empty months
|
|
48
|
+
# filled in — a gap in a histogram should read as zero, not as absence.
|
|
49
|
+
# `from`/`to` widen the span to the window the caller asked about, so a
|
|
50
|
+
# quiet first or last month is shown as quiet rather than dropped.
|
|
51
|
+
def monthly_counts(records, from: nil, to: nil)
|
|
52
|
+
months = records.filter_map(&:month).sort
|
|
53
|
+
first = [from, months.first].compact.min
|
|
54
|
+
last = [to, months.last].compact.max
|
|
55
|
+
return {} if first.nil? || last.nil? || first > last
|
|
56
|
+
|
|
57
|
+
all_months(first, last).to_h do |month|
|
|
58
|
+
[month, months.count(month)]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def chart_row(month, count, peak, label_width)
|
|
65
|
+
bar = BAR_CHARS * bar_length(count, peak)
|
|
66
|
+
"#{@output.gray(month)} #{count.to_s.rjust(label_width)} #{bar}".rstrip
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def row(record, name_width, tenures = {}, tenure_width = 0)
|
|
70
|
+
line = "#{@output.gray((record.date || 'unknown').ljust(DATE_WIDTH))} " \
|
|
71
|
+
"#{truncate(record.best_name.to_s, name_width).ljust(name_width)}"
|
|
72
|
+
line = "#{line} #{@output.gray(tenure_cell(record, tenures, tenure_width))}" if tenure_width.positive?
|
|
73
|
+
title = truncate(record.title.to_s, title_width(name_width, tenure_width))
|
|
74
|
+
title.empty? ? line : "#{line} #{@output.gray(title)}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def tenure_cell(record, tenures, width)
|
|
78
|
+
tenures[record.user_id].to_s.rjust(width)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tenure_column_width(records, tenures)
|
|
82
|
+
return 0 if tenures.nil? || tenures.empty?
|
|
83
|
+
|
|
84
|
+
records.filter_map { |r| tenures[r.user_id]&.to_s&.length }.max || 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def name_column_width(records)
|
|
88
|
+
longest = records.map { |r| r.best_name.to_s.length }.max || 0
|
|
89
|
+
longest.clamp(8, MAX_NAME_WIDTH)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def title_width(name_width, tenure_width = 0)
|
|
93
|
+
tenure_space = tenure_width.positive? ? tenure_width + 2 : 0
|
|
94
|
+
[@width - DATE_WIDTH - name_width - tenure_space - 4, MIN_TITLE_WIDTH].max
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A month nobody left gets no bar at all. Rounding a zero up to one
|
|
98
|
+
# block would draw departures that did not happen.
|
|
99
|
+
def bar_length(count, peak)
|
|
100
|
+
return 0 if peak.zero? || count.zero?
|
|
101
|
+
|
|
102
|
+
available = (@width - 16).clamp(10, MAX_BAR_WIDTH)
|
|
103
|
+
[((count.to_f / peak) * available).round, 1].max
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def truncate(text, width)
|
|
107
|
+
return text if text.length <= width
|
|
108
|
+
|
|
109
|
+
"#{text[0, width - 1]}…"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def all_months(first, last)
|
|
113
|
+
(month_index(first)..month_index(last)).map do |index|
|
|
114
|
+
format('%<year>04d-%<month>02d', year: index / 12, month: (index % 12) + 1)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def month_index(month)
|
|
119
|
+
year, mon = month.split('-').map(&:to_i)
|
|
120
|
+
(year * 12) + (mon - 1)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -26,6 +26,7 @@ module Slk
|
|
|
26
26
|
@color = color.nil? ? io.tty? : color
|
|
27
27
|
@verbose = verbose
|
|
28
28
|
@quiet = quiet
|
|
29
|
+
@last_progress_width = nil
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def puts(message = '')
|
|
@@ -52,6 +53,44 @@ module Slk
|
|
|
52
53
|
puts(colorize(message))
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
# Transient progress on stderr: it never pollutes piped stdout, and it
|
|
57
|
+
# overwrites itself rather than scrolling. Silent under --quiet, and
|
|
58
|
+
# when stderr is not a terminal, since a log file full of half-drawn
|
|
59
|
+
# counters helps nobody.
|
|
60
|
+
def progress(message)
|
|
61
|
+
return unless progress?
|
|
62
|
+
|
|
63
|
+
@last_progress_width = message.length
|
|
64
|
+
write_progress("\r#{message}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Erases whatever progress() last drew. Keyed off the saved width rather
|
|
68
|
+
# than re-checking tty state: if a line was drawn, it gets cleaned up.
|
|
69
|
+
def clear_progress
|
|
70
|
+
return unless @last_progress_width
|
|
71
|
+
|
|
72
|
+
write_progress("\r#{' ' * @last_progress_width}\r")
|
|
73
|
+
@last_progress_width = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# This is decoration. It is often called from an ensure block cleaning
|
|
77
|
+
# up after a real failure, and a closed or broken stderr must not
|
|
78
|
+
# replace that failure with one about drawing a counter.
|
|
79
|
+
def write_progress(text)
|
|
80
|
+
@err.print(text)
|
|
81
|
+
@err.flush
|
|
82
|
+
rescue SystemCallError, IOError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def progress? = tty_err? && !@quiet
|
|
87
|
+
|
|
88
|
+
def tty_err?
|
|
89
|
+
@err.tty?
|
|
90
|
+
rescue SystemCallError, IOError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
55
94
|
def debug(message)
|
|
56
95
|
return unless @verbose
|
|
57
96
|
|
|
@@ -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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# How long someone was here: a start date from their Slack profile and the
|
|
6
|
+
# date their account was deactivated.
|
|
7
|
+
#
|
|
8
|
+
# Both ends are softer than they look. The start date is whatever an admin
|
|
9
|
+
# typed into a custom profile field, and the end is the account's `updated`
|
|
10
|
+
# timestamp. Neither is a payroll record, so this rounds to whole months
|
|
11
|
+
# and refuses to imply a precision it does not have.
|
|
12
|
+
Tenure = Data.define(:started_on, :ended_on) do
|
|
13
|
+
# @param started [String, nil] ISO date from the profile field
|
|
14
|
+
# @param ended [Time, nil] deactivation time
|
|
15
|
+
def self.build(started, ended)
|
|
16
|
+
date = parse_date(started)
|
|
17
|
+
return nil unless date
|
|
18
|
+
|
|
19
|
+
new(started_on: date, ended_on: ended ? to_date(ended) : nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.parse_date(value)
|
|
23
|
+
text = value.to_s.strip
|
|
24
|
+
return nil unless /\A\d{4}-\d{2}-\d{2}\z/.match?(text)
|
|
25
|
+
|
|
26
|
+
Date.iso8601(text)
|
|
27
|
+
rescue Date::Error
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.to_date(time)
|
|
32
|
+
Date.new(time.year, time.month, time.day)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Nil when the account is still active, or when the end predates the
|
|
36
|
+
# start — a start date typed in after the fact can land anywhere, and a
|
|
37
|
+
# negative tenure is a data entry error, not a fact about a person.
|
|
38
|
+
def months
|
|
39
|
+
return nil unless ended_on && ended_on >= started_on
|
|
40
|
+
|
|
41
|
+
ended_on.day < started_on.day ? month_span - 1 : month_span
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def month_span
|
|
45
|
+
((ended_on.year - started_on.year) * 12) + (ended_on.month - started_on.month)
|
|
46
|
+
end
|
|
47
|
+
private :month_span
|
|
48
|
+
|
|
49
|
+
# "6y 2mo", "11mo", "<1mo" — whole months only.
|
|
50
|
+
def to_s
|
|
51
|
+
total = months
|
|
52
|
+
return '' unless total
|
|
53
|
+
return '<1mo' if total.zero?
|
|
54
|
+
|
|
55
|
+
years, rest = total.divmod(12)
|
|
56
|
+
[years.positive? ? "#{years}y" : nil, rest.positive? ? "#{rest}mo" : nil].compact.join(' ')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# True when the length cannot be worked out: an active account, or an
|
|
60
|
+
# end date that predates the start.
|
|
61
|
+
def unknown? = months.nil?
|
|
62
|
+
|
|
63
|
+
def started = started_on.iso8601
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -232,7 +232,7 @@ module Slk
|
|
|
232
232
|
end
|
|
233
233
|
|
|
234
234
|
def parse_success_response(response)
|
|
235
|
-
result =
|
|
235
|
+
result = parse_body(response.body)
|
|
236
236
|
raise_rate_limit(response) if result['error'] == 'ratelimited'
|
|
237
237
|
unless result['ok']
|
|
238
238
|
message = result['error'] || 'Unknown error'
|
|
@@ -240,6 +240,17 @@ module Slk
|
|
|
240
240
|
end
|
|
241
241
|
|
|
242
242
|
result
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Every caller treats a response as a Hash and digs into it. Slack
|
|
246
|
+
# sending a bare array, string or null is not something any of them can
|
|
247
|
+
# act on, so it fails here as an API error rather than as a TypeError
|
|
248
|
+
# somewhere downstream.
|
|
249
|
+
def parse_body(body)
|
|
250
|
+
result = JSON.parse(body)
|
|
251
|
+
return result if result.is_a?(Hash)
|
|
252
|
+
|
|
253
|
+
raise ApiError.new('Unexpected response shape from Slack API', code: :invalid_response)
|
|
243
254
|
rescue JSON::ParserError
|
|
244
255
|
raise ApiError.new('Invalid JSON response from Slack API', code: :invalid_json)
|
|
245
256
|
end
|
|
@@ -135,6 +135,19 @@ module Slk
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# The meta cache holds everything that is neither a user nor a channel:
|
|
139
|
+
# start dates, the deactivation roster, resolved profiles. "Clear all
|
|
140
|
+
# caches" was not telling the truth while this was left behind.
|
|
141
|
+
def clear_meta_cache(workspace_name = nil)
|
|
142
|
+
if workspace_name
|
|
143
|
+
@meta_cache.delete(workspace_name)
|
|
144
|
+
FileUtils.rm_f(meta_cache_file(workspace_name))
|
|
145
|
+
else
|
|
146
|
+
@meta_cache.clear
|
|
147
|
+
Dir.glob(@paths.cache_file('meta-*.json')).each { |f| FileUtils.rm_f(f) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
138
151
|
# Subteam cache methods
|
|
139
152
|
def get_subteam(workspace_name, subteam_id)
|
|
140
153
|
load_subteam_cache(workspace_name)
|
|
@@ -199,6 +212,12 @@ module Slk
|
|
|
199
212
|
@on_warning&.call("#{cache_type} cache corrupted for #{workspace_name}: #{e.message}. Cache will be rebuilt.")
|
|
200
213
|
safely_delete_file(file)
|
|
201
214
|
{}
|
|
215
|
+
rescue SystemCallError, IOError => e
|
|
216
|
+
# An unreadable cache file is the same situation as a corrupt one:
|
|
217
|
+
# carry on without it. Not deleted, because a file we cannot read is
|
|
218
|
+
# one we probably cannot remove either, and it may not be ours.
|
|
219
|
+
@on_warning&.call("#{cache_type} cache unreadable for #{workspace_name}: #{e.message}. Continuing without it.")
|
|
220
|
+
{}
|
|
202
221
|
end
|
|
203
222
|
|
|
204
223
|
def safely_delete_file(file)
|
|
@@ -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
|
|
@@ -7,6 +7,8 @@ module Slk
|
|
|
7
7
|
module MetaCache
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
# A write failure here is deliberately dropped: fetch's caller wants the
|
|
11
|
+
# value, and callers that need to report a cold cache use write directly.
|
|
10
12
|
def fetch(cache_store, workspace_name, key, ttl: nil, refresh: false)
|
|
11
13
|
cached = read(cache_store, workspace_name, key, ttl: ttl) unless refresh
|
|
12
14
|
return cached if cached
|
|
@@ -22,10 +24,23 @@ module Slk
|
|
|
22
24
|
cache_store.get_meta(workspace_name, key, ttl: ttl)
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
# A cache write that fails must never cost the caller the work it just
|
|
28
|
+
# did — a full or read-only disk means "no cache", not "no answer", and
|
|
29
|
+
# some of these writes sit behind minutes of rate-limited API calls.
|
|
30
|
+
#
|
|
31
|
+
# A nil or false value is treated as nothing to store, since no caller
|
|
32
|
+
# caches a negative this way — the start date lookup caches per-user
|
|
33
|
+
# nils inside a Hash, which is a value like any other.
|
|
34
|
+
#
|
|
35
|
+
# @return [Exception, nil] the write failure, for callers that want to
|
|
36
|
+
# mention it; nil when the write succeeded or there was nothing to do
|
|
25
37
|
def write(cache_store, workspace_name, key, value)
|
|
26
|
-
return unless cache_store && workspace_name && value
|
|
38
|
+
return nil unless cache_store && workspace_name && value
|
|
27
39
|
|
|
28
40
|
cache_store.set_meta(workspace_name, key, value)
|
|
41
|
+
nil
|
|
42
|
+
rescue SystemCallError, IOError => e
|
|
43
|
+
e
|
|
29
44
|
end
|
|
30
45
|
end
|
|
31
46
|
end
|