slk 0.11.2 → 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 +6 -0
- data/README.md +10 -0
- data/lib/slk/api/threads.rb +30 -0
- 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/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,12 @@ 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
|
+
|
|
10
16
|
## [0.11.2] - 2026-09-24
|
|
11
17
|
|
|
12
18
|
### Changed
|
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
|
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
|
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
|
|
@@ -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
|