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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7db83e915de4b86bafd0cc62e8a67fdaee0dcd9209cb94a1f8fd6a5a2704c46
4
- data.tar.gz: eb25d2aafaa23a51016e64c2f5eb40c0117aeacb829d784caff9a50078946992
3
+ metadata.gz: 663f35d4a747ac42d0fb9596caa7dd7b4809d7f3451b811b7fb7e8389c7a991c
4
+ data.tar.gz: 42e1ba0fda08b83c644b77212155ab2c68ac318d95f4966060624f5f0eb94827
5
5
  SHA512:
6
- metadata.gz: 2d928dbf2906c22dc3ad108503c6ef4bfaeb68c29c0ed562dcd27e3515cf1468df426801dc6f7e73a3c2f9bb47d79fd7bf90054b3a9fc424453a35e8f60fd75d
7
- data.tar.gz: 93850e6e114cd1634ce2546631b1c370eb94731ff284eb29c05c458a533f16fb1229afde2ca27f60e6ea2e00d12828d6db17342f1d336350cc59d5ae788cc1bb
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 members)
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 members)
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} active members)"
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
- active_members: report.active_count,
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
- require 'bigdecimal'
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 = 'deactivations_v1'
14
+ CACHE_KEY = 'deactivations_v2'
15
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
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' => humans.count { |m| !m['deleted'] }
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
- [BigDecimal(conversation.messages.last.ts), conversation.workspace.name.to_s,
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| BigDecimal(root) }, inclusive: true,
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| BigDecimal(message.ts) }
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
- BigDecimal(timestamp) > BigDecimal(@since_ts)
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
- [BigDecimal(conversation.first_sent_ts), conversation.workspace.name.to_s,
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 = (BigDecimal(window.last.ts) + @after_seconds).to_s('F')
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(BigDecimal(value).to_r)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.11.0'
4
+ VERSION = '0.11.2'
5
5
  end
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.0
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