slk 0.11.1 → 0.12.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 +12 -0
- data/README.md +20 -2
- data/lib/slk/api/threads.rb +30 -0
- data/lib/slk/commands/deactivations.rb +41 -2
- data/lib/slk/commands/debug.rb +39 -1
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/thread.rb +84 -4
- data/lib/slk/services/deactivation_scanner.rb +36 -5
- data/lib/slk/services/thread_subscription.rb +59 -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: daeb158ed43f78f1945fe8b66d24d9e9566096d560d7981d49827006f02e95c0
|
|
4
|
+
data.tar.gz: 30061321cb8d8e2476aa75897fe0830f557eb24f818360db74e9184a74eaaa72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e69f27340cd5072b22e026eb8a429e1e3d5c9f91d8ae11d74f4c216fe7b133c322871e20d6566d9237db32dc7a9f769eaf142aaf43954cab0cdf36be37c965c
|
|
7
|
+
data.tar.gz: da29b5d82c8eb18e1e25c4ce65da977c309770946fc29b62f5de95a10394b9739eb4771fce9f4691ffe9a2d8ca5edeee1b10839e319fd9c4b64db30289ecdd64
|
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.12.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `slk thread subscribe <url>` follows a thread without posting in it, and `slk thread unsubscribe <url>` stops following it. The URL can point at the parent or any reply. Existing replies are marked read, so only new replies surface under Threads in `slk unread` and Slack's Threads view. `--json` reports the channel, thread, and last-read timestamps. Requires a session (`xoxc`) token; other token types get a hint instead of a bare API error.
|
|
15
|
+
|
|
16
|
+
## [0.11.2] - 2026-09-24
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `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.
|
|
21
|
+
|
|
10
22
|
## [0.11.1] - 2026-09-23
|
|
11
23
|
|
|
12
24
|
### Fixed
|
data/README.md
CHANGED
|
@@ -149,6 +149,16 @@ slk messages general -n 50 # Show 50 messages
|
|
|
149
149
|
slk messages general --json # Output as JSON
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
+
### Threads
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
slk thread <url> # View a thread
|
|
156
|
+
slk thread subscribe <url> # Follow a thread without replying
|
|
157
|
+
slk thread unsubscribe <url> # Stop following it
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Subscribing treats existing replies as read, so only new replies show up under Threads in `slk unread` (and in Slack's own Threads view). Requires a session (`xoxc`) token.
|
|
161
|
+
|
|
152
162
|
### Activity
|
|
153
163
|
|
|
154
164
|
```bash
|
|
@@ -225,7 +235,8 @@ slk deactivations --json # Machine-readable output
|
|
|
225
235
|
```
|
|
226
236
|
|
|
227
237
|
```
|
|
228
|
-
acme: 16 deactivated since 30d (664 active
|
|
238
|
+
acme: 16 deactivated since 30d (664 active accounts)
|
|
239
|
+
360 full members · 120 multi-channel guests · 184 single-channel guests
|
|
229
240
|
|
|
230
241
|
2026-09-14 Dana Whitfield Platform Support
|
|
231
242
|
2026-09-11 Priya Raghunathan UX Researcher
|
|
@@ -239,6 +250,12 @@ for that user. For a deactivated account that change is almost always the
|
|
|
239
250
|
deactivation, but an admin editing a departed profile afterwards moves the date
|
|
240
251
|
forward, so treat it as "last touched" rather than a payroll record.
|
|
241
252
|
|
|
253
|
+
The active-account count excludes bots and deactivated accounts; full members and
|
|
254
|
+
both types of Slack guests are shown separately. It is not an employee headcount.
|
|
255
|
+
The same breakdown is available in `--json` as `active_full_members`,
|
|
256
|
+
`active_multi_channel_guests`, and `active_single_channel_guests` (alongside the
|
|
257
|
+
existing `active_members` total).
|
|
258
|
+
|
|
242
259
|
The roster costs one API call per 1000 members, so the result is cached for six
|
|
243
260
|
hours; `--refresh` re-fetches it.
|
|
244
261
|
|
|
@@ -248,7 +265,8 @@ hours; `--refresh` re-fetches it.
|
|
|
248
265
|
"Start Date" profile field:
|
|
249
266
|
|
|
250
267
|
```
|
|
251
|
-
acme: 3 deactivated since 30d (664 active
|
|
268
|
+
acme: 3 deactivated since 30d (664 active accounts)
|
|
269
|
+
360 full members · 120 multi-channel guests · 184 single-channel guests
|
|
252
270
|
|
|
253
271
|
2026-09-14 Dana Whitfield 1mo Platform Support
|
|
254
272
|
2026-09-11 Priya Raghunathan 2y 7mo UX Researcher
|
data/lib/slk/api/threads.rb
CHANGED
|
@@ -28,6 +28,30 @@ module Slk
|
|
|
28
28
|
})
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Subscribe to (follow) a thread so new replies land in the Threads view
|
|
32
|
+
# Requires a session (xoxc) token; this is an undocumented client endpoint.
|
|
33
|
+
# @param channel [String] Channel ID
|
|
34
|
+
# @param thread_ts [String] Thread parent timestamp
|
|
35
|
+
# @param last_read [String] Latest reply timestamp to treat as already read
|
|
36
|
+
def subscribe(channel:, thread_ts:, last_read:)
|
|
37
|
+
@api.post_form(@workspace, 'subscriptions.thread.add', subscription_params(channel, thread_ts, last_read))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Unsubscribe from (unfollow) a thread
|
|
41
|
+
# @param channel [String] Channel ID
|
|
42
|
+
# @param thread_ts [String] Thread parent timestamp
|
|
43
|
+
# @param last_read [String] Latest reply timestamp to treat as already read
|
|
44
|
+
def unsubscribe(channel:, thread_ts:, last_read:)
|
|
45
|
+
@api.post_form(@workspace, 'subscriptions.thread.remove', subscription_params(channel, thread_ts, last_read))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check whether you are subscribed to a thread
|
|
49
|
+
# @param channel [String] Channel ID
|
|
50
|
+
# @param thread_ts [String] Thread parent timestamp
|
|
51
|
+
def get(channel:, thread_ts:)
|
|
52
|
+
@api.post_form(@workspace, 'subscriptions.thread.get', { channel: channel, thread_ts: thread_ts })
|
|
53
|
+
end
|
|
54
|
+
|
|
31
55
|
# Get unread thread count
|
|
32
56
|
# @return [Integer] Number of unread thread replies
|
|
33
57
|
def unread_count
|
|
@@ -40,6 +64,12 @@ module Slk
|
|
|
40
64
|
def unreads?
|
|
41
65
|
unread_count.positive?
|
|
42
66
|
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def subscription_params(channel, thread_ts, last_read)
|
|
71
|
+
{ channel: channel, thread_ts: thread_ts, last_read: last_read }
|
|
72
|
+
end
|
|
43
73
|
end
|
|
44
74
|
end
|
|
45
75
|
end
|
|
@@ -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".
|
data/lib/slk/commands/debug.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Slk
|
|
|
7
7
|
# and field shapes before building higher-level features.
|
|
8
8
|
#
|
|
9
9
|
# Not registered in `slk help`.
|
|
10
|
+
# rubocop:disable Metrics/ClassLength
|
|
10
11
|
class Debug < Base
|
|
11
12
|
def execute
|
|
12
13
|
result = validate_options
|
|
@@ -24,6 +25,7 @@ module Slk
|
|
|
24
25
|
in ['profile'] then dump_profile(nil)
|
|
25
26
|
in ['team'] then dump_team
|
|
26
27
|
in ['schema'] then dump_schema
|
|
28
|
+
in ['thread', url] then dump_thread(url)
|
|
27
29
|
else unknown_action
|
|
28
30
|
end
|
|
29
31
|
end
|
|
@@ -55,6 +57,40 @@ module Slk
|
|
|
55
57
|
0
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# Raw shapes behind thread subscriptions: the parent message (does it
|
|
61
|
+
# carry `subscribed`/`last_read`?) and subscriptions.thread.get. Each call
|
|
62
|
+
# is captured separately so one failing does not hide the other.
|
|
63
|
+
def dump_thread(url)
|
|
64
|
+
resolved = resolve_thread(url)
|
|
65
|
+
ts = resolved.thread_ts || resolved.msg_ts
|
|
66
|
+
raise UsageError, 'URL must point to a specific message' unless ts
|
|
67
|
+
|
|
68
|
+
output.puts(JSON.pretty_generate(thread_calls(resolved.workspace.name, resolved.channel_id, ts)))
|
|
69
|
+
0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def resolve_thread(url)
|
|
73
|
+
Services::TargetResolver.new(runner: runner, cache_store: cache_store)
|
|
74
|
+
.resolve(url, default_workspace: runner.workspace(@options[:workspace]))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def thread_calls(workspace_name, channel, thread_ts)
|
|
78
|
+
{
|
|
79
|
+
'conversations.replies' => capture do
|
|
80
|
+
runner.conversations_api(workspace_name).replies(channel: channel, timestamp: thread_ts, limit: 1)
|
|
81
|
+
end,
|
|
82
|
+
'subscriptions.thread.get' => capture do
|
|
83
|
+
runner.threads_api(workspace_name).get(channel: channel, thread_ts: thread_ts)
|
|
84
|
+
end
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def capture
|
|
89
|
+
yield
|
|
90
|
+
rescue ApiError => e
|
|
91
|
+
{ 'ok' => false, 'error' => e.message }
|
|
92
|
+
end
|
|
93
|
+
|
|
58
94
|
def resolve_user_id(workspace, user_input)
|
|
59
95
|
return self_user_id(workspace) if user_input.nil? || user_input == 'me'
|
|
60
96
|
return user_input if user_input.match?(/\A[UW][A-Z0-9]+\z/)
|
|
@@ -82,7 +118,7 @@ module Slk
|
|
|
82
118
|
|
|
83
119
|
def unknown_action
|
|
84
120
|
error("Unknown debug action: #{positional_args.first.inspect}")
|
|
85
|
-
error('Valid actions: profile [user], team, schema')
|
|
121
|
+
error('Valid actions: profile [user], team, schema, thread <url>')
|
|
86
122
|
1
|
|
87
123
|
end
|
|
88
124
|
|
|
@@ -98,6 +134,7 @@ module Slk
|
|
|
98
134
|
profile [user] Dump users.profile.get + users.info + team.profile.get
|
|
99
135
|
team Dump team.info
|
|
100
136
|
schema Dump team.profile.get
|
|
137
|
+
thread <url> Dump thread parent + subscriptions.thread.get
|
|
101
138
|
|
|
102
139
|
USER
|
|
103
140
|
(none) | me Self
|
|
@@ -106,5 +143,6 @@ module Slk
|
|
|
106
143
|
HELP
|
|
107
144
|
end
|
|
108
145
|
end
|
|
146
|
+
# rubocop:enable Metrics/ClassLength
|
|
109
147
|
end
|
|
110
148
|
end
|
data/lib/slk/commands/help.rb
CHANGED
|
@@ -11,6 +11,7 @@ module Slk
|
|
|
11
11
|
['presence', 'Get or set your presence (away/active)'],
|
|
12
12
|
['dnd', 'Manage Do Not Disturb'],
|
|
13
13
|
['messages', 'Read channel or DM messages'],
|
|
14
|
+
['thread', 'View a thread, or subscribe to it without replying'],
|
|
14
15
|
['search', 'Search messages across channels'],
|
|
15
16
|
['sent', 'Show your sent messages across workspaces'],
|
|
16
17
|
['unread', 'View and clear unread messages'],
|
data/lib/slk/commands/thread.rb
CHANGED
|
@@ -4,13 +4,19 @@ require_relative 'messages'
|
|
|
4
4
|
|
|
5
5
|
module Slk
|
|
6
6
|
module Commands
|
|
7
|
-
# Views a message thread from a Slack URL
|
|
7
|
+
# Views a message thread from a Slack URL, or subscribes to / unsubscribes
|
|
8
|
+
# from it so new replies surface without posting in the thread
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
8
10
|
class Thread < Messages
|
|
11
|
+
SUBSCRIPTION_ACTIONS = %w[subscribe unsubscribe].freeze
|
|
12
|
+
# Session-only endpoint: bot/user OAuth tokens get one of these back.
|
|
13
|
+
TOKEN_ERRORS = %i[not_allowed_token_type unknown_method missing_scope].freeze
|
|
14
|
+
|
|
9
15
|
def execute
|
|
10
16
|
result = validate_options
|
|
11
17
|
return result if result
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
dispatch
|
|
14
20
|
rescue ApiError => e
|
|
15
21
|
error("Failed to fetch messages: #{e.message}")
|
|
16
22
|
1
|
|
@@ -19,6 +25,13 @@ module Slk
|
|
|
19
25
|
1
|
|
20
26
|
end
|
|
21
27
|
|
|
28
|
+
def dispatch
|
|
29
|
+
action = positional_args.first
|
|
30
|
+
return change_subscription(action, positional_args[1]) if SUBSCRIPTION_ACTIONS.include?(action)
|
|
31
|
+
|
|
32
|
+
resolve_and_display_thread
|
|
33
|
+
end
|
|
34
|
+
|
|
22
35
|
def resolve_and_display_thread
|
|
23
36
|
target = positional_args.first
|
|
24
37
|
return usage_error unless target
|
|
@@ -30,6 +43,16 @@ module Slk
|
|
|
30
43
|
fetch_and_display_messages(resolved)
|
|
31
44
|
end
|
|
32
45
|
|
|
46
|
+
def change_subscription(action, target)
|
|
47
|
+
return subscription_usage_error(action) unless target
|
|
48
|
+
return url_required_error unless Support::SlackUrlParser.new.parse(target)&.message?
|
|
49
|
+
|
|
50
|
+
resolved = target_resolver.resolve(target, default_workspace: target_workspaces.first)
|
|
51
|
+
report_subscription(action, resolved.workspace, apply_subscription(action, resolved))
|
|
52
|
+
rescue ApiError => e
|
|
53
|
+
subscription_error(action, e)
|
|
54
|
+
end
|
|
55
|
+
|
|
33
56
|
def fetch_and_display_messages(resolved)
|
|
34
57
|
ts = resolved.thread_ts || resolved.msg_ts
|
|
35
58
|
return message_url_required_error unless ts
|
|
@@ -46,6 +69,12 @@ module Slk
|
|
|
46
69
|
|
|
47
70
|
def usage_error
|
|
48
71
|
error('Usage: slk thread <url>')
|
|
72
|
+
error(' slk thread subscribe|unsubscribe <url>')
|
|
73
|
+
1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def subscription_usage_error(action)
|
|
77
|
+
error("Usage: slk thread #{action} <url>")
|
|
49
78
|
1
|
|
50
79
|
end
|
|
51
80
|
|
|
@@ -60,9 +89,11 @@ module Slk
|
|
|
60
89
|
end
|
|
61
90
|
|
|
62
91
|
def help_text
|
|
63
|
-
help = Support::HelpFormatter.new('slk thread <url> [options]')
|
|
64
|
-
help.description('View a message thread from a Slack URL.')
|
|
92
|
+
help = Support::HelpFormatter.new('slk thread [subscribe|unsubscribe] <url> [options]')
|
|
93
|
+
help.description('View a message thread from a Slack URL, or subscribe to it without replying.')
|
|
94
|
+
help.note('Subscribing marks existing replies read; new replies show up in `slk unread`.')
|
|
65
95
|
add_usage_section(help)
|
|
96
|
+
add_actions_section(help)
|
|
66
97
|
add_options_section(help)
|
|
67
98
|
add_examples_section(help)
|
|
68
99
|
help.render
|
|
@@ -70,10 +101,56 @@ module Slk
|
|
|
70
101
|
|
|
71
102
|
private
|
|
72
103
|
|
|
104
|
+
def apply_subscription(action, resolved)
|
|
105
|
+
thread_subscription(resolved.workspace).public_send(
|
|
106
|
+
action, channel_id: resolved.channel_id, timestamp: resolved.thread_ts || resolved.msg_ts
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def thread_subscription(workspace)
|
|
111
|
+
Services::ThreadSubscription.new(
|
|
112
|
+
conversations_api: runner.conversations_api(workspace.name),
|
|
113
|
+
threads_api: runner.threads_api(workspace.name)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def report_subscription(action, workspace, result)
|
|
118
|
+
if @options[:json]
|
|
119
|
+
output_json(subscription_json(action, workspace, result))
|
|
120
|
+
else
|
|
121
|
+
verb = action == 'subscribe' ? 'Subscribed to' : 'Unsubscribed from'
|
|
122
|
+
success("#{verb} thread #{result.thread_ts} in #{channel_label(workspace, result.channel_id)}")
|
|
123
|
+
end
|
|
124
|
+
0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def subscription_json(action, workspace, result)
|
|
128
|
+
{ subscribed: action == 'subscribe', workspace: workspace.name, channel_id: result.channel_id,
|
|
129
|
+
thread_ts: result.thread_ts, last_read: result.last_read }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def channel_label(workspace, channel_id)
|
|
133
|
+
name = cache_store.get_channel_name(workspace.name, channel_id)
|
|
134
|
+
name ? "##{name}" : channel_id
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def subscription_error(action, err)
|
|
138
|
+
error("Failed to #{action} #{action == 'subscribe' ? 'to' : 'from'} thread: #{err.message}")
|
|
139
|
+
error('Thread subscriptions require a session (xoxc) token.') if TOKEN_ERRORS.include?(err.code)
|
|
140
|
+
1
|
|
141
|
+
end
|
|
142
|
+
|
|
73
143
|
def add_usage_section(help)
|
|
74
144
|
help.section('USAGE') { |s| s.item('<slack_url>', 'Slack message URL') }
|
|
75
145
|
end
|
|
76
146
|
|
|
147
|
+
def add_actions_section(help)
|
|
148
|
+
help.section('ACTIONS') do |s|
|
|
149
|
+
s.action('subscribe <url>', 'Follow the thread (get notified of new replies)')
|
|
150
|
+
s.action('unsubscribe <url>', 'Stop following the thread')
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
77
154
|
def add_options_section(help)
|
|
78
155
|
help.section('OPTIONS') do |s|
|
|
79
156
|
s.option('--no-emoji', 'Show :emoji: codes instead of unicode')
|
|
@@ -88,8 +165,11 @@ module Slk
|
|
|
88
165
|
def add_examples_section(help)
|
|
89
166
|
help.section('EXAMPLES') do |s|
|
|
90
167
|
s.item('slk thread https://work.slack.com/archives/C123/p1234567890', 'View thread')
|
|
168
|
+
s.item('slk thread subscribe https://work.slack.com/archives/C123/p1234567890', 'Follow thread')
|
|
169
|
+
s.item('slk thread unsubscribe https://work.slack.com/archives/C123/p1234567890', 'Unfollow thread')
|
|
91
170
|
end
|
|
92
171
|
end
|
|
93
172
|
end
|
|
173
|
+
# rubocop:enable Metrics/ClassLength
|
|
94
174
|
end
|
|
95
175
|
end
|
|
@@ -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)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Subscribes to or unsubscribes from a thread without posting in it.
|
|
6
|
+
#
|
|
7
|
+
# Slack wants the thread's parent timestamp plus a last-read marker. A
|
|
8
|
+
# permalink can point at any reply, so the parent is looked up first and
|
|
9
|
+
# its latest reply becomes the marker: what is already there counts as
|
|
10
|
+
# read, and only replies posted after subscribing show up as unread.
|
|
11
|
+
class ThreadSubscription
|
|
12
|
+
Result = Data.define(:channel_id, :thread_ts, :last_read)
|
|
13
|
+
|
|
14
|
+
def initialize(conversations_api:, threads_api:)
|
|
15
|
+
@conversations = conversations_api
|
|
16
|
+
@threads = threads_api
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param channel_id [String] Channel ID
|
|
20
|
+
# @param timestamp [String] Timestamp of the parent or any reply
|
|
21
|
+
# @return [Result]
|
|
22
|
+
def subscribe(channel_id:, timestamp:)
|
|
23
|
+
change(:subscribe, channel_id, timestamp)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param channel_id [String] Channel ID
|
|
27
|
+
# @param timestamp [String] Timestamp of the parent or any reply
|
|
28
|
+
# @return [Result]
|
|
29
|
+
def unsubscribe(channel_id:, timestamp:)
|
|
30
|
+
change(:unsubscribe, channel_id, timestamp)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def change(action, channel_id, timestamp)
|
|
36
|
+
result = locate(channel_id, timestamp)
|
|
37
|
+
@threads.public_send(action, channel: channel_id, thread_ts: result.thread_ts, last_read: result.last_read)
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def locate(channel_id, timestamp)
|
|
42
|
+
message = first_message(channel_id, timestamp)
|
|
43
|
+
thread_ts = message['thread_ts'] || message['ts'] || timestamp
|
|
44
|
+
# Pointed at a reply: only the parent carries latest_reply.
|
|
45
|
+
message = first_message(channel_id, thread_ts) if message['ts'] && message['ts'] != thread_ts
|
|
46
|
+
|
|
47
|
+
Result.new(channel_id: channel_id, thread_ts: thread_ts, last_read: message['latest_reply'] || thread_ts)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def first_message(channel_id, timestamp)
|
|
51
|
+
response = @conversations.replies(channel: channel_id, timestamp: timestamp, limit: 1)
|
|
52
|
+
message = (response['messages'] || []).first
|
|
53
|
+
raise ApiError.new('thread_not_found', code: :thread_not_found) unless message
|
|
54
|
+
|
|
55
|
+
message
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -117,6 +117,7 @@ module Slk
|
|
|
117
117
|
autoload :SentConversations, 'slk/services/sent_conversations'
|
|
118
118
|
autoload :SentChanges, 'slk/services/sent_changes'
|
|
119
119
|
autoload :SentChannelLabel, 'slk/services/sent_channel_label'
|
|
120
|
+
autoload :ThreadSubscription, 'slk/services/thread_subscription'
|
|
120
121
|
end
|
|
121
122
|
|
|
122
123
|
# Output formatters for messages, durations, and emoji
|
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.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -127,6 +127,7 @@ files:
|
|
|
127
127
|
- lib/slk/services/start_date_field.rb
|
|
128
128
|
- lib/slk/services/start_date_lookup.rb
|
|
129
129
|
- lib/slk/services/target_resolver.rb
|
|
130
|
+
- lib/slk/services/thread_subscription.rb
|
|
130
131
|
- lib/slk/services/token_loader.rb
|
|
131
132
|
- lib/slk/services/token_saver.rb
|
|
132
133
|
- lib/slk/services/token_store.rb
|