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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 663f35d4a747ac42d0fb9596caa7dd7b4809d7f3451b811b7fb7e8389c7a991c
4
- data.tar.gz: 42e1ba0fda08b83c644b77212155ab2c68ac318d95f4966060624f5f0eb94827
3
+ metadata.gz: daeb158ed43f78f1945fe8b66d24d9e9566096d560d7981d49827006f02e95c0
4
+ data.tar.gz: 30061321cb8d8e2476aa75897fe0830f557eb24f818360db74e9184a74eaaa72
5
5
  SHA512:
6
- metadata.gz: bd98ff12ddd5d0dfcf0ebc33e72852bfffed272c4136b74ed4b63b41ef38c0aab01b28826a02069738556781bec325a63b73eab66407f72c099de9186a6b56c2
7
- data.tar.gz: 92a34fac64238598233edf4b8273d4597bb80deb07b316504649e21da575b4f56e75969a5178f2035c5fbaf5f0fa2ac70fe1eeb2e89d5baa3d6054285991a59a
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
@@ -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
@@ -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
@@ -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'],
@@ -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
- resolve_and_display_thread
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.11.2'
4
+ VERSION = '0.12.0'
5
5
  end
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.11.2
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