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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6377478c56d25efc2b234bcd4ab2118bbe394520ff4f48aed0da0db5bb6c2cea
4
- data.tar.gz: 869096e0ca163dc27042e199e5b2f4f44c81cbdc176ad00ed8546b59cdac7553
3
+ metadata.gz: daeb158ed43f78f1945fe8b66d24d9e9566096d560d7981d49827006f02e95c0
4
+ data.tar.gz: 30061321cb8d8e2476aa75897fe0830f557eb24f818360db74e9184a74eaaa72
5
5
  SHA512:
6
- metadata.gz: c5ce6c57a9978ad2152a3267e9892274883a0980ee33f2648fe7ca14718f870ea3a0a7ecfa2420abffdea18b565c500bfda1524f2efc29bb73a35eaa9de56318
7
- data.tar.gz: ffb2bd3791481c90223a9050760769868194e8044ffc0750704965180b5dfb750cdad1dd4a297f17e1688fc7e9e41c07a231f0b87d85e0af485a2c221e518f2b
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 members)
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 members)
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
@@ -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} 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".
@@ -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
@@ -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)
@@ -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.1'
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.1
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