slack_line 1.1 → 1.3

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: 63f2270ed13b9bf4d14585497109923c70b8e7ec8a34af3ebb46306afdfaf703
4
- data.tar.gz: 677793b2b72ef6f1d58673b9c658151b3b22504e85cb34678d6e0973d23f00a6
3
+ metadata.gz: '091877d65cd3c6b2c21d869608359fbc6c9774f663399eb01b2fec9afc098da3'
4
+ data.tar.gz: 848360f12adbca1f5108764f450f448deac06a4fd61efcc8b994e3a00d166128
5
5
  SHA512:
6
- metadata.gz: 939add34dd7ecd05b44828f53bfd20d62b4c0df9a4deb2b2b2097f736b6a3666173f098e81451416865c0ff66765320b2bff16c8558230eb445a54a3673e9be1
7
- data.tar.gz: 9dc08564bfb998fe4abe612f546a4f9327924ea70e01a153d5daaa751baae3698296b14cb13bc7c50ff612e31f9f1f54dcf6086a74bd5579a17257307027f2d6
6
+ metadata.gz: b1b2ea474330ad8a53da29f5c74e271bfe17392c578dc8f7e2829767e52d4a7952dfb7929a76571236b83b336e7d254a735d9a7b49302ad9c0192f67b4fced92
7
+ data.tar.gz: 2ab43cc4c5c4e02236cadbcaceac55146d9fc118246224494e9210b7e1e7eb21f0cbb04ffc2e8d5af901a0cd2e563ce721ca748e574ab1ac3226add51e4ee80a
@@ -8,7 +8,7 @@ jobs:
8
8
  strategy:
9
9
  fail-fast: false
10
10
  matrix:
11
- ruby-version: ['3.2', '3.3', '3.4', 'head']
11
+ ruby-version: ['3.2', '3.3', '3.4', '4.0', 'head']
12
12
 
13
13
  steps:
14
14
  - uses: actions/checkout@v4
data/CHANGELOG.md CHANGED
@@ -9,3 +9,15 @@ can do each of those things directly (for simpler messages).
9
9
  ## Version 1.1
10
10
 
11
11
  Fixed incorrect description/summary on gemspec.
12
+
13
+ ## Version 1.2
14
+
15
+ Added disk-caching for the users and groups lists, through the `cache_path` and
16
+ `cache_duration` config options and matching CLI flags. Added the
17
+ `slack_line_stateful_thread` script, and `content` and `update` on `SentThread`.
18
+
19
+ ## Version 1.3
20
+
21
+ `message` and `thread` now accept `to:`, as the README showed. Declared `reline`
22
+ as a runtime dependency, which fixes a warning or `LoadError` under Bundler on
23
+ Ruby 4.0. The invalid content error now lists the classes of the bad arguments.
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
1
  source "https://gem.coop"
2
2
 
3
3
  gemspec
4
+
5
+ gem "lightly"
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Eric Mueller
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -80,6 +80,19 @@ be set via ENV or `SlackLine.configure`:
80
80
  * `per_thread_delay` or `SLACK_LINE_PER_THREAD_DELAY` is a float as
81
81
  well - SlackLine will `sleep` for this duration after each _thread_
82
82
  is posted, and after each non-thread message is posted.
83
+ * `cache_path` or `SLACK_LINE_CACHE_PATH` - a directory path for
84
+ disk-caching the users and groups lists fetched from the Slack API.
85
+ When set, SlackLine will use the
86
+ [lightly](https://github.com/DannyBen/lightly)
87
+ gem to cache results to disk, which avoids redundant API calls across
88
+ separate runs. Requires `lightly` to be installed as an optional
89
+ dependency - if it is not available, a `DiskCaching::NoLightly` error
90
+ will be raised at runtime.
91
+ * `cache_duration` or `SLACK_LINE_CACHE_DURATION` - how long cached
92
+ data remains valid (default `"15m"`). Accepts a plain integer number
93
+ of seconds, or a number followed by a unit suffix: `s` (seconds),
94
+ `m` (minutes), `h` (hours), or `d` (days). For example: `"30m"`,
95
+ `"2h"`, `"1d"`, or `"900"`.
83
96
 
84
97
  You can just set those via the environment variables, but you can also
85
98
  set them on the singleton configuration object:
@@ -92,10 +105,12 @@ SlackLine.configure do |config|
92
105
  config.default_channel = "#ci-flow"
93
106
  config.per_message_delay = 0.2
94
107
  config.per_thread_delay = 2.0
108
+ config.cache_path = "/tmp/slack_line_cache"
109
+ config.cache_duration = "1h"
95
110
  end
96
111
  ```
97
112
 
98
- ## Multiple Configurations
113
+ ### Multiple Configurations
99
114
 
100
115
  If you're working in a context where you need to support multiple
101
116
  SlackLine configurations, don't worry! The singleton central config is
@@ -122,15 +137,140 @@ BAR_SLACK.thread("Message 1", "Message 2").post
122
137
  BAR_SLACK.message("Message 3", to: "#bar-team-3").post
123
138
  ```
124
139
 
140
+ ## CLI Scripts
141
+
142
+ The gem ships with three executable scripts for sending and managing Slack
143
+ messages from the command line. All three accept `-t`/`--slack-token TOKEN` and
144
+ `-n`/`--bot-name NAME` to override the corresponding environment variables.
145
+ Configuration can also come from `SLACK_LINE_SLACK_TOKEN` and friends as
146
+ described above.
147
+
148
+ ### `slack_line_message`
149
+
150
+ Sends, updates, or previews a single Slack message.
151
+
152
+ ```sh
153
+ # Post a simple message
154
+ slack_line_message --post-to "#general" --save /tmp/msg.json "Something happened!"
155
+
156
+ # Preview without posting (prints JSON block kit content)
157
+ slack_line_message "Something happened!"
158
+
159
+ # Post a message using the block-kit DSL on stdin
160
+ echo 'text "Something happened!"' | slack_line_message --post-to "#general" --save /tmp/msg.json
161
+
162
+ # Append a reply to an existing thread (saved from a prior post)
163
+ slack_line_message --append /tmp/msg.json --save /tmp/msg.json "Follow-up!"
164
+
165
+ # Update a previously-sent message in place
166
+ slack_line_message --update /tmp/msg.json "Edited message text"
167
+
168
+ # Update a specific message within a saved thread (0-indexed)
169
+ slack_line_message --update /tmp/msg.json --message-number 2 "Corrected reply"
170
+ ```
171
+
172
+ Options:
173
+
174
+ * `-p`/`--post-to TARGET` - channel or user to post to
175
+ * `-a`/`--append PATH` - append a reply to the thread saved at PATH
176
+ * `-U`/`--update PATH` - update the message (or thread) saved at PATH
177
+ * `-m`/`--message-number N` - which message in a thread to update (0-indexed;
178
+ required with `--update` on a thread)
179
+ * `-s`/`--save PATH` - write the sent/updated result to PATH as JSON
180
+ * `-u`/`--look-up-users` - resolve `@mentions` via the Slack API
181
+ * `--cache-path PATH` / `--cache-duration DURATION` - disk-cache user/group
182
+ lookups
183
+ * `--no-backoff` - disable per-message sleep delays
184
+
185
+ When no content arguments are given and no DSL is piped, the script reads DSL
186
+ interactively from stdin.
187
+
188
+ ### `slack_line_thread`
189
+
190
+ Sends or previews a thread (multiple messages posted together).
191
+
192
+ ```sh
193
+ # Post a thread from positional string arguments
194
+ slack_line_thread --post-to "#general" --save /tmp/thread.json "First" "Second" "Third"
195
+
196
+ # Preview the thread without posting
197
+ slack_line_thread "First" "Second"
198
+
199
+ # Post a thread from block-kit DSL on stdin
200
+ cat thread.dsl | slack_line_thread --post-to "#general" --save /tmp/thread.json
201
+ ```
202
+
203
+ A DSL block looks like:
204
+
205
+ ```ruby
206
+ message "Simple first message"
207
+ message do
208
+ text "Fancier second message"
209
+ context "with some context"
210
+ end
211
+ ```
212
+
213
+ Options mirror `slack_line_message`, minus the update/append flags:
214
+
215
+ * `-p`/`--post-to TARGET` - channel or user to post to
216
+ * `-s`/`--save PATH` - write the sent result to PATH as JSON
217
+ * `-u`/`--look-up-users`, `--cache-path`, `--cache-duration`, `--no-backoff` -
218
+ same as above
219
+
220
+ ### `slack_line_stateful_thread`
221
+
222
+ Designed for long-running processes that need to post a single status message
223
+ and then keep it updated as state changes - for example, a deployment pipeline
224
+ that posts `[running] Deploy started`, updates it to `[done] Deploy finished`,
225
+ and appends replies along the way. The sent message is persisted to a file;
226
+ subsequent invocations load and re-persist that file to know which Slack message
227
+ to update.
228
+
229
+ Messages are formatted as `[STATE] body`. The `--state` and `--message` flags
230
+ each update their respective part independently; omitting one leaves it
231
+ unchanged on update.
232
+
233
+ ```sh
234
+ # Initial post - creates /tmp/deploy.json and posts "[running] Deploy started"
235
+ slack_line_stateful_thread --path /tmp/deploy.json --post-to "#deploys" \
236
+ --state running --message "Deploy started"
237
+
238
+ # Update the state only - becomes "[done] Deploy started"
239
+ slack_line_stateful_thread --path /tmp/deploy.json --state done
240
+
241
+ # Update the body only - becomes "[done] Deploy finished"
242
+ slack_line_stateful_thread --path /tmp/deploy.json --message "Deploy finished"
243
+
244
+ # Update both state and body - becomes "[failed] Something went wrong"
245
+ slack_line_stateful_thread --path /tmp/deploy.json --state failed --message "Something went wrong"
246
+
247
+ # Append a thread reply without changing the main message
248
+ slack_line_stateful_thread --path /tmp/deploy.json --thread --message "Step 1 complete"
249
+ ```
250
+
251
+ Options:
252
+
253
+ * `--path PATH` - (required) file path used to persist the sent message between
254
+ invocations
255
+ * `-p`/`--post-to TARGET` - channel or user; required on the first call,
256
+ forbidden thereafter
257
+ * `-s`/`--state STATE` - the state label shown in brackets; required on first
258
+ call
259
+ * `-m`/`--message MESSAGE` - the message body; required on first call and when
260
+ using `--thread`
261
+ * `--thread` - append a reply instead of updating the main message (mutually
262
+ exclusive with `--state`)
263
+
125
264
  ## Slack App Permissions
126
265
 
127
266
  In order to post/update messages, the app behind your `SLACK_LINE_TOKEN` can use
128
267
  these permissions:
129
268
 
130
269
  * `chat:write` - send messages at all.
131
- * `chat:write.public` - send messages to public channels your app _isn't a member
132
- of_ (so you don't need to invite them to the relevant channels to make them work).
270
+ * `chat:write.public` - send messages to public channels your app _isn't a
271
+ member of_ (so you don't need to invite them to the relevant channels to make
272
+ them work).
133
273
  * `im:write` - start direct messages with individuals.
134
- * `users:read` and `usergroups:read` - look up users/groups for (a) messaging them
135
- directly or (b) supporting the `look_up_users` config option (for those more
136
- restrictive workspaces)
274
+ * `users:read` and `usergroups:read` - look up users/groups for (a) messaging
275
+ them directly or (b) supporting the `look_up_users` config option (for those
276
+ more restrictive workspaces)
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/slack_line"
4
+
5
+ begin
6
+ SlackLine::Cli::SlackLineStatefulThread.new(argv: ARGV).run
7
+ rescue SlackLine::Cli::ExitException => e
8
+ abort e.message
9
+ end
@@ -20,11 +20,13 @@ module SlackLine
20
20
  else
21
21
  run_preview
22
22
  end
23
+ rescue DiskCaching::NoLightly, Configuration::InvalidValue => e
24
+ raise ExitException, e.message
23
25
  end
24
26
 
25
27
  def options
26
28
  return @options if defined?(@options)
27
- opts = {post_to: nil, append: nil, update: nil, message_number: nil, save: nil, slack_token: nil, look_up_users: nil, bot_name: nil, backoff: nil}
29
+ opts = {post_to: nil, append: nil, update: nil, message_number: nil, save: nil, slack_token: nil, look_up_users: nil, bot_name: nil, backoff: nil, cache_path: nil, cache_duration: nil}
28
30
  remaining = option_parser(opts).parse(@argv.dup)
29
31
  if remaining.empty?
30
32
  opts[:dsl] = read_stdin
@@ -37,7 +39,7 @@ module SlackLine
37
39
 
38
40
  def configuration
39
41
  return @configuration if defined?(@configuration)
40
- cfg_opts = options.slice(:slack_token, :look_up_users, :bot_name, :backoff).compact
42
+ cfg_opts = options.slice(:slack_token, :look_up_users, :bot_name, :backoff, :cache_path, :cache_duration).compact
41
43
  @configuration = Configuration.new(nil, **cfg_opts)
42
44
  end
43
45
 
@@ -56,6 +58,8 @@ module SlackLine
56
58
  parser.on("-m", "--message-number N", Integer) { |n| opts[:message_number] = n }
57
59
  parser.on("-s", "--save PATH") { |p| opts[:save] = p }
58
60
  parser.on("--no-backoff") { opts[:backoff] = false }
61
+ parser.on("--cache-path PATH") { |p| opts[:cache_path] = p }
62
+ parser.on("--cache-duration DURATION") { |d| opts[:cache_duration] = d }
59
63
  end
60
64
  end
61
65
 
@@ -0,0 +1,126 @@
1
+ require "optparse"
2
+
3
+ module SlackLine
4
+ module Cli
5
+ class SlackLineStatefulThread
6
+ def initialize(argv:, stdout: $stdout, stderr: $stderr)
7
+ @argv = argv
8
+ @stdout = stdout
9
+ @stderr = stderr
10
+ end
11
+
12
+ def run
13
+ validate_options!
14
+ if path_exists?
15
+ run_subsequent
16
+ else
17
+ run_initial
18
+ end
19
+ rescue DiskCaching::NoLightly, Configuration::InvalidValue => e
20
+ raise ExitException, e.message
21
+ end
22
+
23
+ def options
24
+ return @options if defined?(@options)
25
+ opts = {path: nil, post_to: nil, state: nil, message: nil, thread: nil, slack_token: nil, bot_name: nil}
26
+ option_parser(opts).parse(@argv.dup)
27
+ @options = opts
28
+ end
29
+
30
+ def configuration
31
+ return @configuration if defined?(@configuration)
32
+ cfg_opts = options.slice(:slack_token, :bot_name).compact
33
+ @configuration = Configuration.new(nil, **cfg_opts)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :stdout, :stderr
39
+
40
+ def option_parser(opts) # rubocop:disable Metrics/AbcSize
41
+ OptionParser.new do |parser|
42
+ parser.on("-t", "--slack-token TOKEN") { |t| opts[:slack_token] = t }
43
+ parser.on("-n", "--bot-name NAME") { |n| opts[:bot_name] = n }
44
+ parser.on("--path PATH") { |p| opts[:path] = p }
45
+ parser.on("-p", "--post-to TARGET") { |t| opts[:post_to] = t }
46
+ parser.on("-s", "--state STATE") { |s| opts[:state] = s }
47
+ parser.on("-m", "--message MESSAGE") { |m| opts[:message] = m }
48
+ parser.on("--thread") { opts[:thread] = true }
49
+ end
50
+ end
51
+
52
+ def validate_options!
53
+ raise ExitException, "--path is required" unless options[:path]
54
+ path_exists? ? validate_subsequent_options! : validate_initial_options!
55
+ end
56
+
57
+ def validate_initial_options!
58
+ raise ExitException, "--thread cannot be used on initial post" if options[:thread]
59
+ raise ExitException, "--post-to is required for initial post" unless options[:post_to]
60
+ raise ExitException, "--state is required for initial post" unless options[:state]
61
+ raise ExitException, "--message is required for initial post" unless options[:message]
62
+ end
63
+
64
+ def validate_subsequent_options!
65
+ raise ExitException, "--post-to cannot be used after initial post" if options[:post_to]
66
+ options[:thread] ? validate_thread_options! : validate_update_options!
67
+ end
68
+
69
+ def validate_thread_options!
70
+ raise ExitException, "--thread cannot be used with --state" if options[:state]
71
+ raise ExitException, "--thread requires --message" unless options[:message]
72
+ end
73
+
74
+ def validate_update_options!
75
+ raise ExitException, "One of --state or --message is required" unless options[:state] || options[:message]
76
+ end
77
+
78
+ def path_exists? = options[:path] && File.exist?(options[:path])
79
+
80
+ def client = @client ||= Client.new(configuration)
81
+
82
+ def load_sent
83
+ @load_sent ||= SlackLine.from_json(JSON.parse(File.read(options[:path])), client:)
84
+ end
85
+
86
+ def save_sent(result) = File.write(options[:path], JSON.pretty_generate(result.as_json))
87
+
88
+ def run_initial
89
+ text = "[#{options[:state]}] #{options[:message]}"
90
+ sent = Message.new(text, client:).post(to: options[:post_to])
91
+ save_sent(sent)
92
+ stderr.puts "Posted stateful thread to #{options[:post_to]}"
93
+ end
94
+
95
+ def run_subsequent
96
+ options[:thread] ? run_thread_message : run_update_message
97
+ end
98
+
99
+ def parse_state_message(text)
100
+ match = text&.match(/\A\[([^\]]*)\] (.+)\z/m)
101
+ raise ExitException, "Cannot parse state and body from stored message content" unless match
102
+ [match[1], match[2]]
103
+ end
104
+
105
+ def current_state_and_body(sent)
106
+ parse_state_message(sent.content.dig(0, "text", "text"))
107
+ end
108
+
109
+ def run_update_message
110
+ sent = load_sent
111
+ current_state, current_body = current_state_and_body(sent)
112
+ new_text = "[#{options[:state] || current_state}] #{options[:message] || current_body}"
113
+ updated = sent.update(Message.new(new_text, client:))
114
+ save_sent(updated)
115
+ stderr.puts "Updated message in #{updated.channel}"
116
+ end
117
+
118
+ def run_thread_message
119
+ sent = load_sent
120
+ sent_thread = sent.append(options[:message])
121
+ save_sent(sent_thread)
122
+ stderr.puts "Threaded message in #{sent_thread.channel}"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -16,11 +16,13 @@ module SlackLine
16
16
  else
17
17
  run_preview
18
18
  end
19
+ rescue DiskCaching::NoLightly, Configuration::InvalidValue => e
20
+ raise ExitException, e.message
19
21
  end
20
22
 
21
23
  def options
22
24
  return @options if defined?(@options)
23
- opts = {post_to: nil, save: nil, slack_token: nil, look_up_users: nil, bot_name: nil, backoff: nil}
25
+ opts = {post_to: nil, save: nil, slack_token: nil, look_up_users: nil, bot_name: nil, backoff: nil, cache_path: nil, cache_duration: nil}
24
26
  remaining = option_parser(opts).parse(@argv.dup)
25
27
  if remaining.empty?
26
28
  opts[:dsl] = read_stdin
@@ -32,7 +34,7 @@ module SlackLine
32
34
 
33
35
  def configuration
34
36
  return @configuration if defined?(@configuration)
35
- cfg_opts = options.slice(:slack_token, :look_up_users, :bot_name, :backoff).compact
37
+ cfg_opts = options.slice(:slack_token, :look_up_users, :bot_name, :backoff, :cache_path, :cache_duration).compact
36
38
  @configuration = Configuration.new(nil, **cfg_opts)
37
39
  end
38
40
 
@@ -48,6 +50,8 @@ module SlackLine
48
50
  parser.on("-p", "--post-to TARGET") { |t| opts[:post_to] = t }
49
51
  parser.on("-s", "--save PATH") { |p| opts[:save] = p }
50
52
  parser.on("--no-backoff") { opts[:backoff] = false }
53
+ parser.on("--cache-path PATH") { |p| opts[:cache_path] = p }
54
+ parser.on("--cache-duration DURATION") { |d| opts[:cache_duration] = d }
51
55
  end
52
56
  end
53
57
 
@@ -12,12 +12,12 @@ module SlackLine
12
12
 
13
13
  memoize def slack_client = Slack::Web::Client.new(token: configuration.slack_token)
14
14
 
15
- def message(*text_or_blocks, &dsl_block) = Message.new(*text_or_blocks, client: self, &dsl_block)
15
+ def message(*text_or_blocks, to: nil, &dsl_block) = Message.new(*text_or_blocks, client: self, to:, &dsl_block)
16
16
 
17
- def thread(*messages, &dsl_block) = Thread.new(*messages, client: self, &dsl_block)
17
+ def thread(*messages, to: nil, &dsl_block) = Thread.new(*messages, client: self, to:, &dsl_block)
18
18
 
19
- memoize def users = Users.new(slack_client:)
19
+ memoize def users = Users.new(client: self)
20
20
 
21
- memoize def groups = Groups.new(slack_client:)
21
+ memoize def groups = Groups.new(client: self)
22
22
  end
23
23
  end
@@ -1,12 +1,19 @@
1
1
  module SlackLine
2
2
  class Configuration
3
+ include Memoization
4
+
5
+ InvalidValue = Class.new(Error)
6
+
3
7
  attr_accessor :slack_token,
4
8
  :look_up_users, :bot_name, :default_channel,
5
9
  :per_message_delay, :per_thread_delay,
6
- :backoff
10
+ :backoff, :cache_path
11
+ attr_writer :cache_duration
7
12
 
8
13
  alias_method :look_up_users?, :look_up_users
9
14
 
15
+ memoize def cache_duration = parse_duration(@cache_duration.to_s)
16
+
10
17
  DEFAULTS = {
11
18
  slack_token: nil,
12
19
  look_up_users: false,
@@ -14,7 +21,9 @@ module SlackLine
14
21
  default_channel: nil,
15
22
  per_message_delay: 0.0,
16
23
  per_thread_delay: 0.0,
17
- backoff: true
24
+ backoff: true,
25
+ cache_path: nil,
26
+ cache_duration: "15m"
18
27
  }.freeze
19
28
 
20
29
  def initialize(base_config = nil, **overrides)
@@ -28,6 +37,8 @@ module SlackLine
28
37
  @per_message_delay = cascade(:per_message_delay, "SLACK_LINE_PER_MESSAGE_DELAY", :float)
29
38
  @per_thread_delay = cascade(:per_thread_delay, "SLACK_LINE_PER_THREAD_DELAY", :float)
30
39
  @backoff = cascade(:backoff, "SLACK_LINE_NO_BACKOFF", :inverse_boolean)
40
+ @cache_path = cascade(:cache_path, "SLACK_LINE_CACHE_PATH", :string)
41
+ @cache_duration = cascade(:cache_duration, "SLACK_LINE_CACHE_DURATION", :string)
31
42
  end
32
43
 
33
44
  private
@@ -57,5 +68,15 @@ module SlackLine
57
68
  value
58
69
  end
59
70
  end
71
+
72
+ DURATION_MULTIPLIERS = {s: 1, m: 60, h: 3600, d: 86400}.freeze
73
+
74
+ def parse_duration(value)
75
+ match = value.match(/\A(\d+)([smhd])?\z/)
76
+ raise(InvalidValue, "Invalid duration: #{value.inspect}") unless match
77
+
78
+ digits, unit = match.captures
79
+ digits.to_i * DURATION_MULTIPLIERS.fetch(unit&.to_sym, 1)
80
+ end
60
81
  end
61
82
  end
@@ -0,0 +1,18 @@
1
+ begin
2
+ require "lightly"
3
+ rescue LoadError
4
+ # optional dependency
5
+ end
6
+
7
+ module SlackLine
8
+ module DiskCaching
9
+ NoLightly = Class.new(Error)
10
+
11
+ def cached(config:, key:, &block)
12
+ return yield if config.cache_path.nil?
13
+ raise(NoLightly, "The 'lightly' gem is required for disk caching") unless defined?(Lightly)
14
+
15
+ Lightly.new(dir: config.cache_path, life: config.cache_duration).get(key) { yield }
16
+ end
17
+ end
18
+ end
@@ -1,12 +1,15 @@
1
1
  module SlackLine
2
2
  class Groups
3
3
  include Memoization
4
+ include DiskCaching
4
5
 
5
- def initialize(slack_client:)
6
- @slack_client = slack_client
6
+ def initialize(client:)
7
+ @client = client
7
8
  end
8
9
 
9
- memoize def all = fetch_groups
10
+ memoize def all
11
+ cached(config: client.configuration, key: "groups_all") { fetch_groups }
12
+ end
10
13
 
11
14
  def find(handle:)
12
15
  groups_by_handle[handle.downcase]
@@ -14,7 +17,9 @@ module SlackLine
14
17
 
15
18
  private
16
19
 
17
- attr_reader :slack_client
20
+ attr_reader :client
21
+
22
+ def slack_client = client.slack_client
18
23
 
19
24
  memoize def fetch_groups
20
25
  slack_client.usergroups_list.usergroups || []
@@ -3,14 +3,17 @@ module SlackLine
3
3
  extend Forwardable
4
4
  include Memoization
5
5
 
6
- def initialize(*text_or_blocks, client:, &dsl_block)
6
+ def initialize(*text_or_blocks, client:, to: nil, &dsl_block)
7
7
  @text_or_blocks = text_or_blocks
8
8
  @dsl_block = dsl_block
9
9
  @client = client
10
+ @to = to
10
11
 
11
12
  validate!
12
13
  end
13
14
 
15
+ attr_reader :to
16
+
14
17
  memoize def content
15
18
  if @dsl_block
16
19
  MessageContext.new(&@dsl_block).content
@@ -30,7 +33,7 @@ module SlackLine
30
33
  "https://app.slack.com/block-kit-builder##{escaped_json}"
31
34
  end
32
35
 
33
- def post(to: nil, thread_ts: nil) = MessageSender.new(message: self, client:, to:, thread_ts:).post
36
+ def post(to: nil, thread_ts: nil) = MessageSender.new(message: self, client:, to: to || self.to, thread_ts:).post
34
37
 
35
38
  private
36
39
 
@@ -49,7 +52,7 @@ module SlackLine
49
52
 
50
53
  def validate_type!
51
54
  unless @text_or_blocks.empty? || blocks?(@text_or_blocks) || strings?(@text_or_blocks)
52
- raise(ArgumentError, "Invalid content type: #{@text_or_blocks.class}")
55
+ raise(ArgumentError, "Invalid content type: #{@text_or_blocks.map(&:class).uniq.join(", ")}")
53
56
  end
54
57
  end
55
58
 
@@ -10,9 +10,14 @@ module SlackLine
10
10
  attr_reader :sent_messages
11
11
  alias_method :messages, :sent_messages
12
12
  def_delegators :sent_messages, :each, :map, :size, :first, :last, :empty?
13
- def_delegators :first, :channel, :ts
13
+ def_delegators :first, :channel, :ts, :content
14
14
  alias_method :thread_ts, :ts
15
15
 
16
+ def update(*text_or_blocks, &dsl_block)
17
+ updated_root = first.update(*text_or_blocks, &dsl_block)
18
+ SentThread.new(updated_root, *sent_messages[1..])
19
+ end
20
+
16
21
  def append(*text_or_blocks, &dsl_block)
17
22
  extended = first.append(*text_or_blocks, &dsl_block)
18
23
  SentThread.new(*sent_messages, *extended.sent_messages[1..])
@@ -4,14 +4,17 @@ module SlackLine
4
4
  include Enumerable
5
5
  include Memoization
6
6
 
7
- def initialize(*supplied_messages, client:, &dsl_block)
7
+ def initialize(*supplied_messages, client:, to: nil, &dsl_block)
8
8
  @supplied_messages = supplied_messages
9
9
  @dsl_block = dsl_block
10
10
  @client = client
11
+ @to = to
11
12
 
12
13
  validate!
13
14
  end
14
15
 
16
+ attr_reader :to
17
+
15
18
  # an Array of SlackLine::Messages
16
19
  memoize def messages = message_contents.map { |mc| convert_supplied_message(mc) }
17
20
 
@@ -20,11 +23,12 @@ module SlackLine
20
23
  memoize def builder_urls = messages.map(&:builder_url)
21
24
 
22
25
  def post(to: nil)
26
+ target = to || self.to || messages.filter_map(&:to).first
23
27
  sent_messages = []
24
28
  thread_ts = nil
25
29
 
26
30
  messages.each do |message|
27
- sent = message.post(to:, thread_ts:)
31
+ sent = message.post(to: target, thread_ts:)
28
32
  thread_ts ||= sent.ts
29
33
  sent_messages << sent
30
34
  end
@@ -1,12 +1,15 @@
1
1
  module SlackLine
2
2
  class Users
3
3
  include Memoization
4
+ include DiskCaching
4
5
 
5
- def initialize(slack_client:)
6
- @slack_client = slack_client
6
+ def initialize(client:)
7
+ @client = client
7
8
  end
8
9
 
9
- memoize def all = all_users.reject(&:deleted).reject(&:is_bot)
10
+ memoize def all
11
+ cached(config: client.configuration, key: "users_all") { all_users.reject(&:deleted).reject(&:is_bot) }
12
+ end
10
13
 
11
14
  def find(display_name:)
12
15
  users_by_display_name[display_name.downcase]
@@ -14,7 +17,9 @@ module SlackLine
14
17
 
15
18
  private
16
19
 
17
- attr_reader :slack_client
20
+ attr_reader :client
21
+
22
+ def slack_client = client.slack_client
18
23
 
19
24
  def fetch_page(cursor: nil)
20
25
  params = {limit: 200}
@@ -1,3 +1,3 @@
1
1
  module SlackLine
2
- VERSION = "1.1".freeze
2
+ VERSION = "1.3".freeze
3
3
  end
data/slack_line.gemspec CHANGED
@@ -46,4 +46,5 @@ Gem::Specification.new do |spec|
46
46
 
47
47
  spec.add_dependency "slack-ruby-block-kit", "~> 0.23.0"
48
48
  spec.add_dependency "slack-ruby-client", "~> 3.1.0"
49
+ spec.add_dependency "reline", ">= 0.6"
49
50
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slack_line
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.1'
4
+ version: '1.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Mueller
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-02 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -164,6 +164,20 @@ dependencies:
164
164
  - - "~>"
165
165
  - !ruby/object:Gem::Version
166
166
  version: 3.1.0
167
+ - !ruby/object:Gem::Dependency
168
+ name: reline
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0.6'
174
+ type: :runtime
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0.6'
167
181
  description: |
168
182
  Sending messages with the Slack API is not that difficult, but I've had
169
183
  to solve the same problems a lot of times at different companies. This
@@ -173,6 +187,7 @@ email:
173
187
  - nevinera@gmail.com
174
188
  executables:
175
189
  - slack_line_message
190
+ - slack_line_stateful_thread
176
191
  - slack_line_thread
177
192
  extensions: []
178
193
  extra_rdoc_files: []
@@ -188,15 +203,19 @@ files:
188
203
  - ".standard.yml"
189
204
  - CHANGELOG.md
190
205
  - Gemfile
206
+ - LICENSE
191
207
  - README.md
192
208
  - bin/slack_line_message
209
+ - bin/slack_line_stateful_thread
193
210
  - bin/slack_line_thread
194
211
  - lib/slack_line.rb
195
212
  - lib/slack_line/cli.rb
196
213
  - lib/slack_line/cli/slack_line_message.rb
214
+ - lib/slack_line/cli/slack_line_stateful_thread.rb
197
215
  - lib/slack_line/cli/slack_line_thread.rb
198
216
  - lib/slack_line/client.rb
199
217
  - lib/slack_line/configuration.rb
218
+ - lib/slack_line/disk_caching.rb
200
219
  - lib/slack_line/groups.rb
201
220
  - lib/slack_line/memoization.rb
202
221
  - lib/slack_line/message.rb