slk 0.11.0 → 0.11.2
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 +12 -0
- data/README.md +10 -2
- data/lib/slk/commands/deactivations.rb +41 -2
- data/lib/slk/commands/messages.rb +1 -2
- data/lib/slk/services/deactivation_scanner.rb +36 -5
- data/lib/slk/services/sent_changes.rb +5 -7
- data/lib/slk/services/sent_conversations.rb +2 -4
- data/lib/slk/support/check_in_time.rb +1 -2
- data/lib/slk/support/decimal_timestamp.rb +45 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 663f35d4a747ac42d0fb9596caa7dd7b4809d7f3451b811b7fb7e8389c7a991c
|
|
4
|
+
data.tar.gz: 42e1ba0fda08b83c644b77212155ab2c68ac318d95f4966060624f5f0eb94827
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd98ff12ddd5d0dfcf0ebc33e72852bfffed272c4136b74ed4b63b41ef38c0aab01b28826a02069738556781bec325a63b73eab66407f72c099de9186a6b56c2
|
|
7
|
+
data.tar.gz: 92a34fac64238598233edf4b8273d4597bb80deb07b316504649e21da575b4f56e75969a5178f2035c5fbaf5f0fa2ac70fe1eeb2e89d5baa3d6054285991a59a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.2] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `slk deactivations` now breaks the active-account total into full members, multi-channel guests, and single-channel guests, excluding bots and deactivated accounts. The summary labels the total as Slack accounts rather than company members, and `--json` adds the same breakdown while retaining `active_members`. An older roster cache is re-fetched so the new counts cannot appear as zeros.
|
|
15
|
+
|
|
16
|
+
## [0.11.1] - 2026-09-23
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `slk sent` and URL-based message history now use built-in rational arithmetic for exact Slack timestamps instead of requiring the optional `bigdecimal` gem. A standalone install on Ruby 3.4+ works without development dependencies; CI checks the packaged gem in an isolated gem home.
|
|
21
|
+
|
|
10
22
|
## [0.11.0] - 2026-09-23
|
|
11
23
|
|
|
12
24
|
### Changed
|
data/README.md
CHANGED
|
@@ -225,7 +225,8 @@ slk deactivations --json # Machine-readable output
|
|
|
225
225
|
```
|
|
226
226
|
|
|
227
227
|
```
|
|
228
|
-
acme: 16 deactivated since 30d (664 active
|
|
228
|
+
acme: 16 deactivated since 30d (664 active accounts)
|
|
229
|
+
360 full members · 120 multi-channel guests · 184 single-channel guests
|
|
229
230
|
|
|
230
231
|
2026-09-14 Dana Whitfield Platform Support
|
|
231
232
|
2026-09-11 Priya Raghunathan UX Researcher
|
|
@@ -239,6 +240,12 @@ for that user. For a deactivated account that change is almost always the
|
|
|
239
240
|
deactivation, but an admin editing a departed profile afterwards moves the date
|
|
240
241
|
forward, so treat it as "last touched" rather than a payroll record.
|
|
241
242
|
|
|
243
|
+
The active-account count excludes bots and deactivated accounts; full members and
|
|
244
|
+
both types of Slack guests are shown separately. It is not an employee headcount.
|
|
245
|
+
The same breakdown is available in `--json` as `active_full_members`,
|
|
246
|
+
`active_multi_channel_guests`, and `active_single_channel_guests` (alongside the
|
|
247
|
+
existing `active_members` total).
|
|
248
|
+
|
|
242
249
|
The roster costs one API call per 1000 members, so the result is cached for six
|
|
243
250
|
hours; `--refresh` re-fetches it.
|
|
244
251
|
|
|
@@ -248,7 +255,8 @@ hours; `--refresh` re-fetches it.
|
|
|
248
255
|
"Start Date" profile field:
|
|
249
256
|
|
|
250
257
|
```
|
|
251
|
-
acme: 3 deactivated since 30d (664 active
|
|
258
|
+
acme: 3 deactivated since 30d (664 active accounts)
|
|
259
|
+
360 full members · 120 multi-channel guests · 184 single-channel guests
|
|
252
260
|
|
|
253
261
|
2026-09-14 Dana Whitfield 1mo Platform Support
|
|
254
262
|
2026-09-11 Priya Raghunathan 2y 7mo UX Researcher
|
|
@@ -309,6 +309,7 @@ module Slk
|
|
|
309
309
|
def render(workspace, report, records)
|
|
310
310
|
formatter = Formatters::DeactivationFormatter.new(output: output, width: @options[:width])
|
|
311
311
|
formatter.summary(summary_line(workspace, report, records))
|
|
312
|
+
render_account_breakdown(formatter, report)
|
|
312
313
|
render_body(formatter, workspace, records)
|
|
313
314
|
footer = footer(report, records)
|
|
314
315
|
return if footer.empty?
|
|
@@ -340,7 +341,36 @@ module Slk
|
|
|
340
341
|
|
|
341
342
|
def summary_line(workspace, report, records)
|
|
342
343
|
"#{workspace.name}: #{records.size} #{scope_phrase} " \
|
|
343
|
-
"(#{report.active_count
|
|
344
|
+
"(#{count_label(report.active_count, 'active account')})"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Keep each guest type visible without overflowing a narrow terminal.
|
|
348
|
+
# rubocop:disable Metrics/MethodLength
|
|
349
|
+
def render_account_breakdown(formatter, report)
|
|
350
|
+
line = ' '
|
|
351
|
+
account_parts(report).each do |part|
|
|
352
|
+
combined = line == ' ' ? "#{line}#{part}" : "#{line} · #{part}"
|
|
353
|
+
if @options[:width] && combined.length > @options[:width] && line != ' '
|
|
354
|
+
formatter.note(line)
|
|
355
|
+
line = " #{part}"
|
|
356
|
+
else
|
|
357
|
+
line = combined
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
formatter.note(line)
|
|
361
|
+
end
|
|
362
|
+
# rubocop:enable Metrics/MethodLength
|
|
363
|
+
|
|
364
|
+
def account_parts(report)
|
|
365
|
+
[
|
|
366
|
+
count_label(report.full_member_count, 'full member'),
|
|
367
|
+
count_label(report.multi_channel_guest_count, 'multi-channel guest'),
|
|
368
|
+
count_label(report.single_channel_guest_count, 'single-channel guest')
|
|
369
|
+
]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def count_label(count, singular)
|
|
373
|
+
"#{count} #{singular}#{'s' unless count == 1}"
|
|
344
374
|
end
|
|
345
375
|
|
|
346
376
|
def scope_phrase
|
|
@@ -390,7 +420,7 @@ module Slk
|
|
|
390
420
|
{
|
|
391
421
|
workspace: workspace.name,
|
|
392
422
|
fetched_at: report.fetched_at,
|
|
393
|
-
|
|
423
|
+
**active_counts_json(report),
|
|
394
424
|
accounts_ever: report.human_count,
|
|
395
425
|
total_deactivated: total_deactivated(report),
|
|
396
426
|
includes_bots: @options[:bots] ? true : false,
|
|
@@ -399,6 +429,15 @@ module Slk
|
|
|
399
429
|
}
|
|
400
430
|
end
|
|
401
431
|
|
|
432
|
+
def active_counts_json(report)
|
|
433
|
+
{
|
|
434
|
+
active_members: report.active_count,
|
|
435
|
+
active_full_members: report.full_member_count,
|
|
436
|
+
active_multi_channel_guests: report.multi_channel_guest_count,
|
|
437
|
+
active_single_channel_guests: report.single_channel_guest_count
|
|
438
|
+
}
|
|
439
|
+
end
|
|
440
|
+
|
|
402
441
|
# started_on and tenure_months appear only when they were asked for:
|
|
403
442
|
# a null that means "not looked up" is indistinguishable from one that
|
|
404
443
|
# means "nobody filled it in".
|
|
@@ -228,8 +228,7 @@ module Slk
|
|
|
228
228
|
|
|
229
229
|
# Adjust a Slack timestamp by a small amount while preserving precision
|
|
230
230
|
def adjust_timestamp(timestamp, delta)
|
|
231
|
-
|
|
232
|
-
(BigDecimal(timestamp) + BigDecimal(delta.to_s)).to_s('F')
|
|
231
|
+
Support::DecimalTimestamp.add(timestamp, delta)
|
|
233
232
|
end
|
|
234
233
|
|
|
235
234
|
def fetch_all_thread_replies(api, channel_id, thread_ts)
|
|
@@ -11,11 +11,17 @@ module Slk
|
|
|
11
11
|
# (deactivated members plus headcounts, not the whole roster) is cached in
|
|
12
12
|
# the workspace meta cache with a TTL.
|
|
13
13
|
class DeactivationScanner
|
|
14
|
-
CACHE_KEY = '
|
|
14
|
+
CACHE_KEY = 'deactivations_v2'
|
|
15
15
|
DEFAULT_TTL = 21_600 # 6 hours
|
|
16
|
-
REQUIRED_KEYS = %w[
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
REQUIRED_KEYS = %w[
|
|
17
|
+
fetched_at member_count human_count active_count full_member_count
|
|
18
|
+
multi_channel_guest_count single_channel_guest_count records
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
Report = Data.define(
|
|
22
|
+
:records, :member_count, :human_count, :active_count, :full_member_count,
|
|
23
|
+
:multi_channel_guest_count, :single_channel_guest_count, :fetched_at
|
|
24
|
+
) do
|
|
19
25
|
def deactivated_count = records.size
|
|
20
26
|
|
|
21
27
|
def bots = records.count(&:bot)
|
|
@@ -70,30 +76,55 @@ module Slk
|
|
|
70
76
|
|
|
71
77
|
def counts(members)
|
|
72
78
|
humans = members.reject { |m| Models::Deactivation.bot?(m) }
|
|
79
|
+
active = humans.reject { |m| m['deleted'] }
|
|
73
80
|
|
|
74
81
|
{
|
|
75
82
|
'member_count' => members.size,
|
|
76
83
|
'human_count' => humans.size,
|
|
77
|
-
'active_count' =>
|
|
84
|
+
'active_count' => active.size
|
|
85
|
+
}.merge(guest_counts(active))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def guest_counts(active)
|
|
89
|
+
groups = active.group_by { |member| guest_type(member) }
|
|
90
|
+
{
|
|
91
|
+
'full_member_count' => groups.fetch(:full, []).size,
|
|
92
|
+
'multi_channel_guest_count' => groups.fetch(:multi, []).size,
|
|
93
|
+
'single_channel_guest_count' => groups.fetch(:single, []).size
|
|
78
94
|
}
|
|
79
95
|
end
|
|
80
96
|
|
|
97
|
+
def guest_type(member)
|
|
98
|
+
return :single if member['is_ultra_restricted']
|
|
99
|
+
return :multi if member['is_restricted']
|
|
100
|
+
|
|
101
|
+
:full
|
|
102
|
+
end
|
|
103
|
+
|
|
81
104
|
def fetch_members
|
|
82
105
|
@users_api.list_all do |total|
|
|
83
106
|
@on_debug&.call("users.list: #{total} members fetched")
|
|
84
107
|
end
|
|
85
108
|
end
|
|
86
109
|
|
|
110
|
+
# Keep the cache fields explicit: a missing headcount must not become a
|
|
111
|
+
# plausible-looking zero without usable? first checking the cache shape.
|
|
112
|
+
# rubocop:disable Metrics/AbcSize
|
|
87
113
|
def build_report(data)
|
|
88
114
|
Report.new(
|
|
89
115
|
records: sorted_records(data['records'] || []),
|
|
90
116
|
member_count: data['member_count'].to_i,
|
|
91
117
|
human_count: data['human_count'].to_i,
|
|
92
118
|
active_count: data['active_count'].to_i,
|
|
119
|
+
full_member_count: data['full_member_count'].to_i,
|
|
120
|
+
multi_channel_guest_count: data['multi_channel_guest_count'].to_i,
|
|
121
|
+
single_channel_guest_count: data['single_channel_guest_count'].to_i,
|
|
93
122
|
fetched_at: data['fetched_at']&.to_i
|
|
94
123
|
)
|
|
95
124
|
end
|
|
96
125
|
|
|
126
|
+
# rubocop:enable Metrics/AbcSize
|
|
127
|
+
|
|
97
128
|
# Newest first; accounts with no usable timestamp sort to the bottom
|
|
98
129
|
# rather than pretending to be from 1970.
|
|
99
130
|
def sorted_records(raw)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'bigdecimal'
|
|
4
|
-
|
|
5
3
|
module Slk
|
|
6
4
|
module Services
|
|
7
5
|
# Stateless diff of watched sent conversations. Search discovers what to
|
|
@@ -32,7 +30,7 @@ module Slk
|
|
|
32
30
|
conversation = change.conversation
|
|
33
31
|
# Put the most recently active conversation last. Trailing identity
|
|
34
32
|
# keys make ties deterministic across platforms (including Windows).
|
|
35
|
-
[
|
|
33
|
+
[Support::DecimalTimestamp.parse(conversation.messages.last.ts), conversation.workspace.name.to_s,
|
|
36
34
|
conversation.channel_id.to_s, conversation.thread_ts.to_s]
|
|
37
35
|
end
|
|
38
36
|
end
|
|
@@ -90,8 +88,8 @@ module Slk
|
|
|
90
88
|
cursor = nil
|
|
91
89
|
loop do
|
|
92
90
|
response = @api.history(channel: hits.first.channel_id,
|
|
93
|
-
oldest: roots.min_by { |root|
|
|
94
|
-
limit: 200, cursor: cursor)
|
|
91
|
+
oldest: roots.min_by { |root| Support::DecimalTimestamp.parse(root) },
|
|
92
|
+
inclusive: true, limit: 200, cursor: cursor)
|
|
95
93
|
response.fetch('messages', []).each do |message|
|
|
96
94
|
found[message['ts']] = message if wanted[message['ts']]
|
|
97
95
|
end
|
|
@@ -253,7 +251,7 @@ module Slk
|
|
|
253
251
|
|
|
254
252
|
def normalize(raw, channel_id)
|
|
255
253
|
raw.map { |row| row.is_a?(Models::Message) ? row : Models::Message.from_api(row, channel_id: channel_id) }
|
|
256
|
-
.uniq(&:ts).sort_by { |message|
|
|
254
|
+
.uniq(&:ts).sort_by { |message| Support::DecimalTimestamp.parse(message.ts) }
|
|
257
255
|
end
|
|
258
256
|
|
|
259
257
|
def after_cutoff(raw)
|
|
@@ -265,7 +263,7 @@ module Slk
|
|
|
265
263
|
end
|
|
266
264
|
|
|
267
265
|
def newer?(timestamp)
|
|
268
|
-
|
|
266
|
+
Support::DecimalTimestamp.parse(timestamp) > Support::DecimalTimestamp.parse(@since_ts)
|
|
269
267
|
end
|
|
270
268
|
|
|
271
269
|
def page_through(endpoint, **params)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'bigdecimal'
|
|
4
|
-
|
|
5
3
|
module Slk
|
|
6
4
|
module Services
|
|
7
5
|
# Expands indexed from:me hits into bounded channel windows, full DM
|
|
@@ -42,7 +40,7 @@ module Slk
|
|
|
42
40
|
def ordered_conversations(conversations)
|
|
43
41
|
conversations.sort_by do |conversation|
|
|
44
42
|
# Keep Slack's fractional timestamp precise; identity breaks ties regardless of input order or platform.
|
|
45
|
-
[
|
|
43
|
+
[Support::DecimalTimestamp.parse(conversation.first_sent_ts), conversation.workspace.name.to_s,
|
|
46
44
|
conversation.channel_id.to_s, conversation.thread_ts.to_s]
|
|
47
45
|
end
|
|
48
46
|
end
|
|
@@ -115,7 +113,7 @@ module Slk
|
|
|
115
113
|
def following_messages(channel_id, window)
|
|
116
114
|
return [] if @after_seconds.zero?
|
|
117
115
|
|
|
118
|
-
end_at = (
|
|
116
|
+
end_at = Support::DecimalTimestamp.add(window.last.ts, @after_seconds)
|
|
119
117
|
page_through(:history, channel: channel_id, oldest: window.first.ts, latest: end_at, inclusive: true)
|
|
120
118
|
end
|
|
121
119
|
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'bigdecimal'
|
|
4
3
|
require 'date'
|
|
5
4
|
require 'time'
|
|
6
5
|
|
|
@@ -31,7 +30,7 @@ module Slk
|
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def self.epoch_time(value)
|
|
34
|
-
Time.at(
|
|
33
|
+
Time.at(DecimalTimestamp.parse(value))
|
|
35
34
|
rescue ArgumentError
|
|
36
35
|
raise UsageError, "Invalid --changed-since time: #{value.inspect}."
|
|
37
36
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Support
|
|
5
|
+
# Exact decimal arithmetic for Slack timestamps without an optional gem.
|
|
6
|
+
module DecimalTimestamp
|
|
7
|
+
def self.parse(value)
|
|
8
|
+
Rational(value.to_s)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.add(timestamp, offset)
|
|
12
|
+
decimal(parse(timestamp) + parse(offset))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.decimal(value)
|
|
16
|
+
twos, remaining = factor(value.denominator, 2)
|
|
17
|
+
fives, remaining = factor(remaining, 5)
|
|
18
|
+
raise ArgumentError, 'Timestamp must have a finite decimal representation' unless remaining == 1
|
|
19
|
+
|
|
20
|
+
places = [twos, fives].max
|
|
21
|
+
scaled = value.numerator * ((10**places) / value.denominator)
|
|
22
|
+
format_digits(scaled, places)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.format_digits(scaled, places)
|
|
26
|
+
digits = scaled.abs.to_s.rjust(places + 1, '0')
|
|
27
|
+
sign = scaled.negative? ? '-' : ''
|
|
28
|
+
return "#{sign}#{digits}.0" if places.zero?
|
|
29
|
+
|
|
30
|
+
"#{sign}#{digits[0...-places]}.#{digits[-places..]}"
|
|
31
|
+
end
|
|
32
|
+
private_class_method :format_digits
|
|
33
|
+
|
|
34
|
+
def self.factor(number, divisor)
|
|
35
|
+
count = 0
|
|
36
|
+
while (number % divisor).zero?
|
|
37
|
+
number /= divisor
|
|
38
|
+
count += 1
|
|
39
|
+
end
|
|
40
|
+
[count, number]
|
|
41
|
+
end
|
|
42
|
+
private_class_method :factor
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -200,6 +200,7 @@ module Slk
|
|
|
200
200
|
autoload :InteractivePrompt, 'slk/support/interactive_prompt'
|
|
201
201
|
autoload :DateParser, 'slk/support/date_parser'
|
|
202
202
|
autoload :CheckInTime, 'slk/support/check_in_time'
|
|
203
|
+
autoload :DecimalTimestamp, 'slk/support/decimal_timestamp'
|
|
203
204
|
autoload :TimeParser, 'slk/support/time_parser'
|
|
204
205
|
autoload :TimeRangeParser, 'slk/support/time_range_parser'
|
|
205
206
|
end
|
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.11.
|
|
4
|
+
version: 0.11.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- lib/slk/services/who_target_resolver.rb
|
|
138
138
|
- lib/slk/support/check_in_time.rb
|
|
139
139
|
- lib/slk/support/date_parser.rb
|
|
140
|
+
- lib/slk/support/decimal_timestamp.rb
|
|
140
141
|
- lib/slk/support/error_logger.rb
|
|
141
142
|
- lib/slk/support/help_formatter.rb
|
|
142
143
|
- lib/slk/support/inline_images.rb
|