slk 0.9.0 → 0.11.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 +47 -0
- data/README.md +37 -0
- data/lib/slk/api/conversations.rb +6 -2
- data/lib/slk/cli.rb +1 -0
- data/lib/slk/commands/cache.rb +10 -9
- data/lib/slk/commands/deactivations.rb +144 -14
- data/lib/slk/commands/help.rb +2 -0
- data/lib/slk/commands/search.rb +55 -48
- data/lib/slk/commands/sent.rb +288 -0
- data/lib/slk/formatters/attachment_formatter.rb +7 -3
- 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 +27 -11
- data/lib/slk/formatters/output.rb +39 -0
- data/lib/slk/formatters/search_formatter.rb +25 -11
- data/lib/slk/formatters/sent_formatter.rb +193 -0
- data/lib/slk/models/search_result.rb +2 -0
- data/lib/slk/models/tenure.rb +66 -0
- data/lib/slk/runner.rb +4 -0
- data/lib/slk/services/api_client.rb +12 -1
- data/lib/slk/services/cache_store.rb +19 -0
- data/lib/slk/services/meta_cache.rb +16 -1
- data/lib/slk/services/search_pages.rb +65 -0
- data/lib/slk/services/sent_changes.rb +293 -0
- data/lib/slk/services/sent_channel_label.rb +66 -0
- data/lib/slk/services/sent_conversations.rb +227 -0
- data/lib/slk/services/start_date_field.rb +67 -0
- data/lib/slk/services/start_date_lookup.rb +98 -0
- data/lib/slk/support/check_in_time.rb +73 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +13 -0
- metadata +14 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Start dates live in a workspace custom profile field, which `users.list`
|
|
6
|
+
# does not return — they cost one `users.profile.get` per person, and Slack
|
|
7
|
+
# rate-limits that endpoint hard enough that a screenful takes minutes
|
|
8
|
+
# rather than seconds.
|
|
9
|
+
#
|
|
10
|
+
# So every answer is cached, and cached the moment it arrives rather than
|
|
11
|
+
# at the end: interrupting a long lookup keeps the work already paid for.
|
|
12
|
+
# Accounts with no start date on file are cached too, otherwise every run
|
|
13
|
+
# would pay again to learn the same nothing.
|
|
14
|
+
#
|
|
15
|
+
# The cache holds for 30 days. A departed account's start date rarely
|
|
16
|
+
# changes, but an admin correcting a typo in one is exactly the case the
|
|
17
|
+
# expiry exists for; `slk cache clear` forces the issue sooner.
|
|
18
|
+
class StartDateLookup
|
|
19
|
+
CACHE_KEY = 'start_dates_v1'
|
|
20
|
+
TTL = 2_592_000 # 30 days
|
|
21
|
+
|
|
22
|
+
class MissingFieldError < Slk::Error; end
|
|
23
|
+
|
|
24
|
+
# Set when the cache could not be written. The lookup carries on — the
|
|
25
|
+
# cache is an optimisation, and the answers are worth more than it.
|
|
26
|
+
attr_reader :cache_error
|
|
27
|
+
|
|
28
|
+
def initialize(users_api:, field:, workspace_name:, cache_store: nil, on_progress: nil)
|
|
29
|
+
@users_api = users_api
|
|
30
|
+
@field = field
|
|
31
|
+
@workspace_name = workspace_name
|
|
32
|
+
@cache = cache_store
|
|
33
|
+
@on_progress = on_progress
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash{String => String, nil}] user ID => ISO date, or nil for
|
|
37
|
+
# an account with no start date recorded
|
|
38
|
+
def fetch(user_ids)
|
|
39
|
+
# Checked even for an empty list: "this workspace cannot do tenure" is
|
|
40
|
+
# the same fact whether or not anyone matched the filter, and finding
|
|
41
|
+
# out only when someone matches makes it look intermittent.
|
|
42
|
+
raise MissingFieldError, @field.missing_message if @field.id.to_s.empty?
|
|
43
|
+
return {} if user_ids.empty?
|
|
44
|
+
|
|
45
|
+
known = cached
|
|
46
|
+
user_ids.each_with_index { |id, index| resolve(known, id, index, user_ids.size) }
|
|
47
|
+
known.slice(*user_ids)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# How many of these would cost an API call, so a caller can warn about
|
|
51
|
+
# the wait before making someone sit through it.
|
|
52
|
+
def uncached_count(user_ids)
|
|
53
|
+
known = cached
|
|
54
|
+
user_ids.count { |id| !known.key?(id) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def resolve(known, user_id, index, total)
|
|
60
|
+
return if known.key?(user_id)
|
|
61
|
+
|
|
62
|
+
@on_progress&.call(index + 1, total)
|
|
63
|
+
known[user_id] = start_date_for(user_id)
|
|
64
|
+
remember(known)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A full or read-only disk must not throw away a lookup that cost
|
|
68
|
+
# minutes of rate-limited calls. MetaCache.write hands back the failure
|
|
69
|
+
# instead of raising; the caller reports it once at the end.
|
|
70
|
+
def remember(known)
|
|
71
|
+
failure = MetaCache.write(@cache, @workspace_name, CACHE_KEY, known)
|
|
72
|
+
# First failure only: the same unwritable disk will fail every row.
|
|
73
|
+
@cache_error = failure.message if failure && @cache_error.nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Which account failed matters when the answer arrives twenty rows into
|
|
77
|
+
# a run that has already taken three minutes.
|
|
78
|
+
def start_date_for(user_id)
|
|
79
|
+
fields = @users_api.profile_for(user_id).dig('profile', 'fields')
|
|
80
|
+
value = fields.is_a?(Hash) ? fields.dig(@field.id, 'value') : nil
|
|
81
|
+
value.to_s.empty? ? nil : value
|
|
82
|
+
rescue ApiError => e
|
|
83
|
+
# Only the plain kind: re-wrapping a RateLimitError would drop its
|
|
84
|
+
# retry_after and the class the retry logic looks for.
|
|
85
|
+
raise unless e.instance_of?(ApiError)
|
|
86
|
+
|
|
87
|
+
raise ApiError.new("#{e.message} (looking up #{user_id})", code: e.code)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cached
|
|
91
|
+
@cached ||= begin
|
|
92
|
+
data = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: TTL)
|
|
93
|
+
data.is_a?(Hash) ? data : {}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
require 'date'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Slk
|
|
8
|
+
module Support
|
|
9
|
+
# A stateless, exact timestamp for sent-conversation change detection.
|
|
10
|
+
class CheckInTime
|
|
11
|
+
RELATIVE = /\A(\d+)([mhd])\z/i
|
|
12
|
+
CLOCK = /\A(\d{1,2}):(\d{2})\z/
|
|
13
|
+
EPOCH = /\A\d{9,12}(?:\.\d{1,6})?\z/
|
|
14
|
+
ISO = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\z/
|
|
15
|
+
|
|
16
|
+
def self.parse(value, now: Time.now)
|
|
17
|
+
time = case value
|
|
18
|
+
when RELATIVE then relative(Regexp.last_match, now)
|
|
19
|
+
when CLOCK then clock(Regexp.last_match, now)
|
|
20
|
+
when EPOCH then epoch_time(value)
|
|
21
|
+
when ISO then iso_time(value)
|
|
22
|
+
else raise UsageError, 'Invalid --changed-since time. Use H:MM or HH:MM, ISO, epoch, 90m, 2h, or 1d.'
|
|
23
|
+
end
|
|
24
|
+
raise UsageError, '--changed-since must not be in the future.' if time > now
|
|
25
|
+
|
|
26
|
+
time
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.timestamp(time)
|
|
30
|
+
format('%<seconds>d.%<micros>06d', seconds: time.to_i, micros: time.usec)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.epoch_time(value)
|
|
34
|
+
Time.at(BigDecimal(value).to_r)
|
|
35
|
+
rescue ArgumentError
|
|
36
|
+
raise UsageError, "Invalid --changed-since time: #{value.inspect}."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.iso_time(value)
|
|
40
|
+
Date.iso8601(value[0, 10])
|
|
41
|
+
with_seconds = value.sub(/(T\d{2}:\d{2})(?=Z|[+-]\d{2}:\d{2}|\z)/, '\\1:00')
|
|
42
|
+
Time.iso8601(with_seconds)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
raise UsageError, "Invalid --changed-since time: #{value.inspect}."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.relative(match, now)
|
|
48
|
+
amount = match[1].to_i
|
|
49
|
+
raise UsageError, '--changed-since duration must be positive.' if amount.zero?
|
|
50
|
+
|
|
51
|
+
seconds = { 'm' => 60, 'h' => 3600, 'd' => 86_400 }.fetch(match[2].downcase)
|
|
52
|
+
now - (amount * seconds)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.clock(match, now)
|
|
56
|
+
hour, minute = match.captures.map(&:to_i)
|
|
57
|
+
raise UsageError, 'Invalid --changed-since clock time.' unless hour < 24 && minute < 60
|
|
58
|
+
|
|
59
|
+
day = now.to_date
|
|
60
|
+
time = local_clock(day, hour, minute)
|
|
61
|
+
return time if time <= now
|
|
62
|
+
|
|
63
|
+
local_clock(day - 1, hour, minute)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.local_clock(day, hour, minute)
|
|
67
|
+
Time.local(day.year, day.month, day.day, hour, minute)
|
|
68
|
+
rescue ArgumentError
|
|
69
|
+
raise UsageError, 'Invalid --changed-since clock time.'
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'json'
|
|
|
6
6
|
require 'fileutils'
|
|
7
7
|
require 'optparse'
|
|
8
8
|
require 'time'
|
|
9
|
+
require 'date'
|
|
9
10
|
require 'io/console'
|
|
10
11
|
|
|
11
12
|
# Slack CLI - A command-line interface for Slack
|
|
@@ -75,6 +76,7 @@ module Slk
|
|
|
75
76
|
autoload :Channel, 'slk/models/channel'
|
|
76
77
|
autoload :Preset, 'slk/models/preset'
|
|
77
78
|
autoload :Deactivation, 'slk/models/deactivation'
|
|
79
|
+
autoload :Tenure, 'slk/models/tenure'
|
|
78
80
|
autoload :SearchResult, 'slk/models/search_result'
|
|
79
81
|
autoload :SavedItem, 'slk/models/saved_item'
|
|
80
82
|
autoload :Profile, 'slk/models/profile'
|
|
@@ -109,6 +111,12 @@ module Slk
|
|
|
109
111
|
autoload :ProfileResolver, 'slk/services/profile_resolver'
|
|
110
112
|
autoload :MetaCache, 'slk/services/meta_cache'
|
|
111
113
|
autoload :DeactivationScanner, 'slk/services/deactivation_scanner'
|
|
114
|
+
autoload :StartDateLookup, 'slk/services/start_date_lookup'
|
|
115
|
+
autoload :StartDateField, 'slk/services/start_date_field'
|
|
116
|
+
autoload :SearchPages, 'slk/services/search_pages'
|
|
117
|
+
autoload :SentConversations, 'slk/services/sent_conversations'
|
|
118
|
+
autoload :SentChanges, 'slk/services/sent_changes'
|
|
119
|
+
autoload :SentChannelLabel, 'slk/services/sent_channel_label'
|
|
112
120
|
end
|
|
113
121
|
|
|
114
122
|
# Output formatters for messages, durations, and emoji
|
|
@@ -126,12 +134,15 @@ module Slk
|
|
|
126
134
|
autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
|
|
127
135
|
autoload :BlockFormatter, 'slk/formatters/block_formatter'
|
|
128
136
|
autoload :SearchFormatter, 'slk/formatters/search_formatter'
|
|
137
|
+
autoload :SentFormatter, 'slk/formatters/sent_formatter'
|
|
129
138
|
autoload :SavedItemFormatter, 'slk/formatters/saved_item_formatter'
|
|
130
139
|
autoload :TextProcessor, 'slk/formatters/text_processor'
|
|
131
140
|
autoload :ProfileFormatter, 'slk/formatters/profile_formatter'
|
|
132
141
|
autoload :ProfileFieldRenderer, 'slk/formatters/profile_field_renderer'
|
|
133
142
|
autoload :ProfileRows, 'slk/formatters/profile_rows'
|
|
134
143
|
autoload :DeactivationFormatter, 'slk/formatters/deactivation_formatter'
|
|
144
|
+
autoload :DeactivationCsv, 'slk/formatters/deactivation_csv'
|
|
145
|
+
autoload :CsvWriter, 'slk/formatters/csv_writer'
|
|
135
146
|
end
|
|
136
147
|
|
|
137
148
|
# CLI commands implementing user-facing functionality
|
|
@@ -146,6 +157,7 @@ module Slk
|
|
|
146
157
|
autoload :Catchup, 'slk/commands/catchup'
|
|
147
158
|
autoload :Activity, 'slk/commands/activity'
|
|
148
159
|
autoload :Search, 'slk/commands/search'
|
|
160
|
+
autoload :Sent, 'slk/commands/sent'
|
|
149
161
|
autoload :Preset, 'slk/commands/preset'
|
|
150
162
|
autoload :Workspaces, 'slk/commands/workspaces'
|
|
151
163
|
autoload :Cache, 'slk/commands/cache'
|
|
@@ -187,6 +199,7 @@ module Slk
|
|
|
187
199
|
autoload :TextWrapper, 'slk/support/text_wrapper'
|
|
188
200
|
autoload :InteractivePrompt, 'slk/support/interactive_prompt'
|
|
189
201
|
autoload :DateParser, 'slk/support/date_parser'
|
|
202
|
+
autoload :CheckInTime, 'slk/support/check_in_time'
|
|
190
203
|
autoload :TimeParser, 'slk/support/time_parser'
|
|
191
204
|
autoload :TimeRangeParser, 'slk/support/time_range_parser'
|
|
192
205
|
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.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- lib/slk/commands/presence.rb
|
|
57
57
|
- lib/slk/commands/preset.rb
|
|
58
58
|
- lib/slk/commands/search.rb
|
|
59
|
+
- lib/slk/commands/sent.rb
|
|
59
60
|
- lib/slk/commands/ssh_key_manager.rb
|
|
60
61
|
- lib/slk/commands/status.rb
|
|
61
62
|
- lib/slk/commands/thread.rb
|
|
@@ -65,6 +66,8 @@ files:
|
|
|
65
66
|
- lib/slk/formatters/activity_formatter.rb
|
|
66
67
|
- lib/slk/formatters/attachment_formatter.rb
|
|
67
68
|
- lib/slk/formatters/block_formatter.rb
|
|
69
|
+
- lib/slk/formatters/csv_writer.rb
|
|
70
|
+
- lib/slk/formatters/deactivation_csv.rb
|
|
68
71
|
- lib/slk/formatters/deactivation_formatter.rb
|
|
69
72
|
- lib/slk/formatters/duration_formatter.rb
|
|
70
73
|
- lib/slk/formatters/emoji_replacer.rb
|
|
@@ -80,6 +83,7 @@ files:
|
|
|
80
83
|
- lib/slk/formatters/reaction_formatter.rb
|
|
81
84
|
- lib/slk/formatters/saved_item_formatter.rb
|
|
82
85
|
- lib/slk/formatters/search_formatter.rb
|
|
86
|
+
- lib/slk/formatters/sent_formatter.rb
|
|
83
87
|
- lib/slk/formatters/text_processor.rb
|
|
84
88
|
- lib/slk/models/channel.rb
|
|
85
89
|
- lib/slk/models/deactivation.rb
|
|
@@ -95,6 +99,7 @@ files:
|
|
|
95
99
|
- lib/slk/models/search_result.rb
|
|
96
100
|
- lib/slk/models/status.rb
|
|
97
101
|
- lib/slk/models/status_snapshot.rb
|
|
102
|
+
- lib/slk/models/tenure.rb
|
|
98
103
|
- lib/slk/models/user.rb
|
|
99
104
|
- lib/slk/models/workspace.rb
|
|
100
105
|
- lib/slk/runner.rb
|
|
@@ -114,7 +119,13 @@ files:
|
|
|
114
119
|
- lib/slk/services/profile_builder.rb
|
|
115
120
|
- lib/slk/services/profile_resolver.rb
|
|
116
121
|
- lib/slk/services/reaction_enricher.rb
|
|
122
|
+
- lib/slk/services/search_pages.rb
|
|
123
|
+
- lib/slk/services/sent_changes.rb
|
|
124
|
+
- lib/slk/services/sent_channel_label.rb
|
|
125
|
+
- lib/slk/services/sent_conversations.rb
|
|
117
126
|
- lib/slk/services/setup_wizard.rb
|
|
127
|
+
- lib/slk/services/start_date_field.rb
|
|
128
|
+
- lib/slk/services/start_date_lookup.rb
|
|
118
129
|
- lib/slk/services/target_resolver.rb
|
|
119
130
|
- lib/slk/services/token_loader.rb
|
|
120
131
|
- lib/slk/services/token_saver.rb
|
|
@@ -124,6 +135,7 @@ files:
|
|
|
124
135
|
- lib/slk/services/user_matcher.rb
|
|
125
136
|
- lib/slk/services/user_picker.rb
|
|
126
137
|
- lib/slk/services/who_target_resolver.rb
|
|
138
|
+
- lib/slk/support/check_in_time.rb
|
|
127
139
|
- lib/slk/support/date_parser.rb
|
|
128
140
|
- lib/slk/support/error_logger.rb
|
|
129
141
|
- lib/slk/support/help_formatter.rb
|
|
@@ -159,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
159
171
|
- !ruby/object:Gem::Version
|
|
160
172
|
version: '0'
|
|
161
173
|
requirements: []
|
|
162
|
-
rubygems_version: 4.0.
|
|
174
|
+
rubygems_version: 4.0.20
|
|
163
175
|
specification_version: 4
|
|
164
176
|
summary: A command-line interface for Slack
|
|
165
177
|
test_files: []
|