slack_line 0.1.0 → 1.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: 61c279b95b271ef984af73457248e82ab75a576aa46820bd59cacd8c8f6c8042
4
- data.tar.gz: 57bc942d244ef3b45d95344ff0ab5bffb4412bc840016d2bf8e55450e1600742
3
+ metadata.gz: 5012be14f249f312a75b1d5cca149fd938eb2d6b66db4b51fb93789b3a4030e9
4
+ data.tar.gz: 6a4244475ce9b64db464f391d6dbfa0c8d6a1dc509854b1b05a6bc1149ad78de
5
5
  SHA512:
6
- metadata.gz: 8dd4210ffac87ab836e38902287098458dc0368d785f22c4a70221cc949cef70a219c0a96ed2adb37098d634e1c98899d1b59719cf41cfd7a248fe4259b12956
7
- data.tar.gz: 20e53e6fa522453b25513f4ddfb503b74a7e1f31153837d7fb1dacfe9c89db19b4e5a2b29839ed24d5ef999da6b8fb16bed94a2fd09c76825afa41a997c29af5
6
+ metadata.gz: 1bb9462733e8c15e292606d303c04d2c72855d9d141ebfd917b9f2bf415b964aad0dd34fd0832c1a74064f551272da0dfce670ae43047828ee0c89a8fd86f6c5
7
+ data.tar.gz: 455afd3746d46319fe637e924034655cfa9f42da2c06d8c30a3f578ed70e2245811f6f7a5404d37939360cdb47309a966485d8151ec41e578724fb494c222a18
data/README.md CHANGED
@@ -121,3 +121,14 @@ BAR_SLACK = SlackLine::Client.new(default_channel: "#team-bar", bot_name: "BarBo
121
121
  BAR_SLACK.thread("Message 1", "Message 2").post
122
122
  BAR_SLACK.message("Message 3", to: "#bar-team-3").post
123
123
  ```
124
+
125
+ ## Slack App Permissions
126
+
127
+ In order to post/update messages, the app behind your `SLACK_LINE_TOKEN` can use these permissions:
128
+
129
+ * `chat:write` - send messages at all.
130
+ * `chat:write.public` - send messages to public channels your app _isn't a member of_ (so you don't
131
+ need to invite them to the relevant channels to make them work).
132
+ * `im:write` - start direct messages with individuals.
133
+ * `users:read` and `usergroups:read` - look up users/groups for (a) messaging them directly or
134
+ (b) supporting the `look_up_users` config option (for those more restrictive workspaces)
@@ -1,49 +1,9 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require_relative "../lib/slack_line"
4
- require "optparse"
5
- require "reline"
6
4
 
7
- options = {
8
- post_to: nil,
9
- content: nil,
10
- dsl: nil
11
- }
12
-
13
- configuration = SlackLine::Configuration.new(SlackLine.configuration)
14
-
15
- OptionParser.new do |opts|
16
- opts.banner = "Usage: build_slack_line_message [options] [content]"
17
-
18
- opts.on("-t", "--slack-token TOKEN", "Slack API token") { |t| configuration.slack_token = t }
19
- opts.on("-u", "--look-up-users", "Enable user look-up") { configuration.look_up_users = true }
20
- opts.on("-n", "--bot-name NAME", "Bot name to use") { |n| configuration.bot_name = n }
21
- opts.on("-p", "--post-to TARGET", "Channel or user post the message to") { |t| options[:post_to] = t }
22
- end.parse!
23
-
24
- if ARGV.empty?
25
- warn "No content provided, reading (as dsl) from stdin. Control+D to finish:\n\n"
26
- options[:dsl] = ""
27
- while (line = Reline.readline("MSG> ", true))
28
- options[:dsl] += line + "\n"
29
- end
30
- else
31
- options[:content] = ARGV.dup
32
- end
33
-
34
- client = SlackLine::Client.new(configuration)
35
-
36
- message =
37
- if options[:content]
38
- SlackLine::Message.new(*options[:content], client:)
39
- else
40
- SlackLine::Message.new(client:) { eval(options[:dsl]) }
41
- end
42
-
43
- if options[:post_to]
44
- message.post(to: options[:post_to])
45
- warn "Posted message to #{options[:post_to]}"
46
- else
47
- warn "Preview message at #{message.builder_url}\n\n"
48
- puts JSON.pretty_generate(message.content.as_json)
5
+ begin
6
+ SlackLine::Cli::SlackLineMessage.new(argv: ARGV).run
7
+ rescue SlackLine::Cli::ExitException => e
8
+ abort e.message
49
9
  end
@@ -1,55 +1,9 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require_relative "../lib/slack_line"
4
- require "optparse"
5
- require "reline"
6
4
 
7
- options = {
8
- post_to: nil,
9
- content: nil,
10
- dsl: nil
11
- }
12
-
13
- configuration = SlackLine::Configuration.new(SlackLine.configuration)
14
-
15
- OptionParser.new do |opts|
16
- opts.banner = "Usage: build_slack_line_message [options] [content]"
17
-
18
- opts.on("-t", "--slack-token TOKEN", "Slack API token") { |t| configuration.slack_token = t }
19
- opts.on("-u", "--look-up-users", "Enable user look-up") { configuration.look_up_users = true }
20
- opts.on("-n", "--bot-name NAME", "Bot name to use") { |n| configuration.bot_name = n }
21
- opts.on("-p", "--post-to TARGET", "Channel or user post the message to") { |t| options[:post_to] = t }
22
- end.parse!
23
-
24
- if ARGV.empty?
25
- warn "No content provided, reading (as dsl) from stdin. Control+D to finish:\n\n"
26
- options[:dsl] = ""
27
- while (line = Reline.readline("THD> ", true))
28
- options[:dsl] += line + "\n"
29
- end
30
- else
31
- options[:content] = ARGV.dup
32
- end
33
-
34
- client = SlackLine::Client.new(configuration)
35
-
36
- thread =
37
- if options[:content]
38
- SlackLine::Thread.new(*options[:content], client:)
39
- else
40
- SlackLine::Thread.new(client:) { eval(options[:dsl]) }
41
- end
42
-
43
- if options[:post_to]
44
- thread.post_to(options[:post_to])
45
- warn "Posted thread to #{options[:post_to]}"
46
- else
47
- warn "Preview messages at:"
48
- thread.builder_urls.each { |url| warn " #{url}" }
49
-
50
- thread.each do |message|
51
- puts "\n\n--------------------- Message ---------------------\n"
52
- puts "Preview at: #{message.builder_url}\n\n"
53
- puts JSON.pretty_generate(message.content.as_json)
54
- end
5
+ begin
6
+ SlackLine::Cli::SlackLineThread.new(argv: ARGV).run
7
+ rescue SlackLine::Cli::ExitException => e
8
+ abort e.message
55
9
  end
@@ -0,0 +1,154 @@
1
+ require "optparse"
2
+ require "reline"
3
+
4
+ module SlackLine
5
+ module Cli
6
+ class SlackLineMessage
7
+ def initialize(argv:, stdout: $stdout, stderr: $stderr)
8
+ @argv = argv
9
+ @stdout = stdout
10
+ @stderr = stderr
11
+ end
12
+
13
+ def run
14
+ if options[:append]
15
+ run_append
16
+ elsif options[:update]
17
+ run_update
18
+ elsif options[:post_to]
19
+ run_post
20
+ else
21
+ run_preview
22
+ end
23
+ end
24
+
25
+ def options
26
+ 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}
28
+ remaining = option_parser(opts).parse(@argv.dup)
29
+ if remaining.empty?
30
+ opts[:dsl] = read_stdin
31
+ else
32
+ opts[:content] = remaining
33
+ end
34
+ validate_options!(opts)
35
+ @options = opts
36
+ end
37
+
38
+ def configuration
39
+ return @configuration if defined?(@configuration)
40
+ cfg_opts = options.slice(:slack_token, :look_up_users, :bot_name, :backoff).compact
41
+ @configuration = Configuration.new(nil, **cfg_opts)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :stdout, :stderr
47
+
48
+ def option_parser(opts) # rubocop:disable Metrics/AbcSize
49
+ OptionParser.new do |parser|
50
+ parser.on("-t", "--slack-token TOKEN") { |t| opts[:slack_token] = t }
51
+ parser.on("-u", "--look-up-users") { opts[:look_up_users] = true }
52
+ parser.on("-n", "--bot-name NAME") { |n| opts[:bot_name] = n }
53
+ parser.on("-p", "--post-to TARGET") { |t| opts[:post_to] = t }
54
+ parser.on("-a", "--append PATH") { |p| opts[:append] = p }
55
+ parser.on("-U", "--update PATH") { |p| opts[:update] = p }
56
+ parser.on("-m", "--message-number N", Integer) { |n| opts[:message_number] = n }
57
+ parser.on("-s", "--save PATH") { |p| opts[:save] = p }
58
+ parser.on("--no-backoff") { opts[:backoff] = false }
59
+ end
60
+ end
61
+
62
+ def validate_options!(opts)
63
+ if [opts[:post_to], opts[:append], opts[:update]].compact.size > 1
64
+ raise(ExitException, "Only one of --post-to, --append, or --update can be used at a time")
65
+ end
66
+ raise ExitException, "--message-number requires --update" if opts[:message_number] && !opts[:update]
67
+ raise ExitException, "--message-number cannot be less than 0" if opts[:message_number] && opts[:message_number] < 0
68
+ end
69
+
70
+ def read_stdin
71
+ stderr.puts "No content provided, reading (as dsl) from stdin. Control+D to finish:\n\n"
72
+ lines = []
73
+ while (line = Reline.readline("MSG> ", true))
74
+ lines << line
75
+ end
76
+ lines.join("\n")
77
+ end
78
+
79
+ def client = @client ||= Client.new(configuration)
80
+
81
+ def load_json(path, client:) = SlackLine.from_json(JSON.parse(File.read(path)), client:)
82
+
83
+ def save(result) = File.write(options[:save], JSON.pretty_generate(result.as_json))
84
+
85
+ def message
86
+ @message ||=
87
+ if options[:content]
88
+ Message.new(*options[:content], client:)
89
+ else
90
+ dsl = options[:dsl]
91
+ Message.new(client:) { eval(dsl) } # standard:disable Security/Eval
92
+ end
93
+ end
94
+
95
+ def append_target = @append_target ||= load_json(options[:append], client:)
96
+
97
+ def run_append
98
+ sent_thread = append_target.append(message)
99
+ stderr.puts "Appended to thread in #{sent_thread.channel}"
100
+ save(sent_thread) if options[:save]
101
+ end
102
+
103
+ def update_target = @update_target ||= load_json(options[:update], client:)
104
+
105
+ def validate_update_flags!
106
+ message_number = options[:message_number]
107
+ if update_target.is_a?(SentMessage)
108
+ raise(ExitException, "--message-number cannot be used when updating a single message") if message_number
109
+ elsif message_number.nil?
110
+ raise(ExitException, "--message-number is required when updating a thread")
111
+ elsif message_number >= update_target.size
112
+ raise(ExitException, "--message-number #{message_number} is out of range (thread has #{update_target.size} messages)")
113
+ end
114
+ end
115
+
116
+ def message_to_update
117
+ return @message_to_update if defined?(@message_to_update)
118
+
119
+ validate_update_flags!
120
+ if update_target.is_a?(SentMessage)
121
+ @message_to_update = update_target
122
+ else
123
+ msg_num = options[:message_number]
124
+ @message_to_update = update_target.sent_messages[msg_num]
125
+ end
126
+ end
127
+
128
+ def run_update
129
+ updated_msg = message_to_update.update(message)
130
+ result = update_target.is_a?(SentThread) ? rebuilt_thread(updated_msg) : updated_msg
131
+ type = update_target.is_a?(SentThread) ? "thread" : "message"
132
+ stderr.puts "Updated #{type} in #{result.channel}"
133
+ save(result) if options[:save]
134
+ end
135
+
136
+ def rebuilt_thread(updated_msg)
137
+ idx = options[:message_number]
138
+ msgs = update_target.sent_messages
139
+ SentThread.new(*msgs[0...idx], updated_msg, *msgs[(idx + 1)..])
140
+ end
141
+
142
+ def run_post
143
+ sent = message.post(to: options[:post_to])
144
+ stderr.puts "Posted message to #{options[:post_to]}"
145
+ save(sent) if options[:save]
146
+ end
147
+
148
+ def run_preview
149
+ stderr.puts "Preview message at #{message.builder_url}\n\n"
150
+ stdout.puts JSON.pretty_generate(message.content.as_json)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,96 @@
1
+ require "optparse"
2
+ require "reline"
3
+
4
+ module SlackLine
5
+ module Cli
6
+ class SlackLineThread
7
+ def initialize(argv:, stdout: $stdout, stderr: $stderr)
8
+ @argv = argv
9
+ @stdout = stdout
10
+ @stderr = stderr
11
+ end
12
+
13
+ def run
14
+ if options[:post_to]
15
+ run_post
16
+ else
17
+ run_preview
18
+ end
19
+ end
20
+
21
+ def options
22
+ return @options if defined?(@options)
23
+ opts = {post_to: nil, save: nil, slack_token: nil, look_up_users: nil, bot_name: nil, backoff: nil}
24
+ remaining = option_parser(opts).parse(@argv.dup)
25
+ if remaining.empty?
26
+ opts[:dsl] = read_stdin
27
+ else
28
+ opts[:content] = remaining
29
+ end
30
+ @options = opts
31
+ end
32
+
33
+ def configuration
34
+ return @configuration if defined?(@configuration)
35
+ cfg_opts = options.slice(:slack_token, :look_up_users, :bot_name, :backoff).compact
36
+ @configuration = Configuration.new(nil, **cfg_opts)
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :stdout, :stderr
42
+
43
+ def option_parser(opts)
44
+ OptionParser.new do |parser|
45
+ parser.on("-t", "--slack-token TOKEN") { |t| opts[:slack_token] = t }
46
+ parser.on("-u", "--look-up-users") { opts[:look_up_users] = true }
47
+ parser.on("-n", "--bot-name NAME") { |n| opts[:bot_name] = n }
48
+ parser.on("-p", "--post-to TARGET") { |t| opts[:post_to] = t }
49
+ parser.on("-s", "--save PATH") { |p| opts[:save] = p }
50
+ parser.on("--no-backoff") { opts[:backoff] = false }
51
+ end
52
+ end
53
+
54
+ def read_stdin
55
+ stderr.puts "No content provided, reading (as dsl) from stdin. Control+D to finish:\n\n"
56
+ lines = []
57
+ while (line = Reline.readline("THD> ", true))
58
+ lines << line
59
+ end
60
+ lines.join("\n")
61
+ end
62
+
63
+ def client = @client ||= Client.new(configuration)
64
+
65
+ def save(result) = File.write(options[:save], JSON.pretty_generate(result.as_json))
66
+
67
+ def thread
68
+ @thread ||=
69
+ if options[:content]
70
+ Thread.new(*options[:content], client:)
71
+ else
72
+ dsl = options[:dsl]
73
+ Thread.new(client:) { eval(dsl) } # standard:disable Security/Eval
74
+ end
75
+ end
76
+
77
+ def run_post
78
+ sent = thread.post(to: options[:post_to])
79
+ stderr.puts "Posted thread to #{options[:post_to]}"
80
+ save(sent) if options[:save]
81
+ end
82
+
83
+ def preview_message(message)
84
+ stdout.puts "\n\n--------------------- Message ---------------------\n"
85
+ stdout.puts "Preview at: #{message.builder_url}\n\n"
86
+ stdout.puts JSON.pretty_generate(message.content.as_json)
87
+ end
88
+
89
+ def run_preview
90
+ stderr.puts "Preview messages at:"
91
+ thread.builder_urls.each { |url| stderr.puts " #{url}" }
92
+ thread.each { |message| preview_message(message) }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,8 @@
1
+ module SlackLine
2
+ module Cli
3
+ ExitException = Class.new(StandardError)
4
+ end
5
+ end
6
+
7
+ glob = File.expand_path("../cli/*.rb", __FILE__)
8
+ Dir.glob(glob).sort.each { |f| require(f) }
@@ -15,5 +15,9 @@ module SlackLine
15
15
  def message(*text_or_blocks, &dsl_block) = Message.new(*text_or_blocks, client: self, &dsl_block)
16
16
 
17
17
  def thread(*messages, &dsl_block) = Thread.new(*messages, client: self, &dsl_block)
18
+
19
+ memoize def users = Users.new(slack_client:)
20
+
21
+ memoize def groups = Groups.new(slack_client:)
18
22
  end
19
23
  end
@@ -2,7 +2,10 @@ module SlackLine
2
2
  class Configuration
3
3
  attr_accessor :slack_token,
4
4
  :look_up_users, :bot_name, :default_channel,
5
- :per_message_delay, :per_thread_delay
5
+ :per_message_delay, :per_thread_delay,
6
+ :backoff
7
+
8
+ alias_method :look_up_users?, :look_up_users
6
9
 
7
10
  DEFAULTS = {
8
11
  slack_token: nil,
@@ -10,7 +13,8 @@ module SlackLine
10
13
  bot_name: nil,
11
14
  default_channel: nil,
12
15
  per_message_delay: 0.0,
13
- per_thread_delay: 0.0
16
+ per_thread_delay: 0.0,
17
+ backoff: true
14
18
  }.freeze
15
19
 
16
20
  def initialize(base_config = nil, **overrides)
@@ -23,6 +27,7 @@ module SlackLine
23
27
  @default_channel = cascade(:default_channel, "SLACK_LINE_DEFAULT_CHANNEL", :string)
24
28
  @per_message_delay = cascade(:per_message_delay, "SLACK_LINE_PER_MESSAGE_DELAY", :float)
25
29
  @per_thread_delay = cascade(:per_thread_delay, "SLACK_LINE_PER_THREAD_DELAY", :float)
30
+ @backoff = cascade(:backoff, "SLACK_LINE_NO_BACKOFF", :inverse_boolean)
26
31
  end
27
32
 
28
33
  private
@@ -44,6 +49,8 @@ module SlackLine
44
49
 
45
50
  if env_type == :boolean
46
51
  %w[1 true yes].include?(value.downcase)
52
+ elsif env_type == :inverse_boolean
53
+ !%w[1 true yes].include?(value.downcase)
47
54
  elsif env_type == :float
48
55
  value.to_f
49
56
  else
@@ -0,0 +1,25 @@
1
+ module SlackLine
2
+ class Groups
3
+ include Memoization
4
+
5
+ def initialize(slack_client:)
6
+ @slack_client = slack_client
7
+ end
8
+
9
+ memoize def all = fetch_groups
10
+
11
+ def find(handle:)
12
+ groups_by_handle[handle.downcase]
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :slack_client
18
+
19
+ memoize def fetch_groups
20
+ slack_client.usergroups_list.usergroups || []
21
+ end
22
+
23
+ memoize def groups_by_handle = all.map { |g| [g.handle.downcase, g] }.to_h
24
+ end
25
+ end
@@ -30,11 +30,7 @@ module SlackLine
30
30
  "https://app.slack.com/block-kit-builder##{escaped_json}"
31
31
  end
32
32
 
33
- def post(to: nil, thread_ts: nil)
34
- target = to || configuration.default_channel || raise(ConfigurationError, "No target channel specified and no default_channel configured.")
35
- response = slack_client.chat_postMessage(channel: target, blocks: content_data, thread_ts:, username: configuration.bot_name)
36
- SentMessage.new(content: content_data, response:, client:)
37
- end
33
+ def post(to: nil, thread_ts: nil) = MessageSender.new(message: self, client:, to:, thread_ts:).post
38
34
 
39
35
  private
40
36
 
@@ -0,0 +1,49 @@
1
+ module SlackLine
2
+ class MessageConverter
3
+ def initialize(blocks, client:)
4
+ @blocks = blocks
5
+ @client = client
6
+ end
7
+
8
+ def convert = resolve_mentions(@blocks)
9
+
10
+ private
11
+
12
+ attr_reader :client
13
+
14
+ def resolve_mentions(json)
15
+ case json
16
+ when Array then resolve_mentions_in_array(json)
17
+ when Hash then resolve_mentions_in_hash(json)
18
+ else json
19
+ end
20
+ end
21
+
22
+ def resolve_mentions_in_array(array)
23
+ array.map { |item| resolve_mentions(item) }
24
+ end
25
+
26
+ def resolve_mentions_in_hash(hash)
27
+ text_key = hash.key?(:text) ? :text : "text"
28
+ type_key = hash.key?(:type) ? :type : "type"
29
+ if hash[type_key] == "mrkdwn" && hash[text_key].is_a?(String)
30
+ hash.merge(text_key => substitute_mentions(hash[text_key]))
31
+ else
32
+ hash.transform_values { |v| resolve_mentions(v) }
33
+ end
34
+ end
35
+
36
+ def substitute_mentions(text)
37
+ text.gsub(/@([\w-]+)/) do |match|
38
+ token = $1
39
+ if (user = client.users.find(display_name: token))
40
+ "<@#{user.id}>"
41
+ elsif (group = client.groups.find(handle: token))
42
+ "<!subteam^#{group.id}>"
43
+ else
44
+ match
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,75 @@
1
+ module SlackLine
2
+ class MessageSender
3
+ extend Forwardable
4
+ include Memoization
5
+
6
+ def initialize(message:, client:, to: nil, thread_ts: nil)
7
+ @message = message
8
+ @client = client
9
+ @to = to
10
+ @thread_ts = thread_ts
11
+ end
12
+
13
+ def post = SentMessage.new(content: content_data, response: api_response, client:)
14
+
15
+ private
16
+
17
+ memoize def supplied_target
18
+ to ||
19
+ configuration.default_channel ||
20
+ raise(ConfigurationError, "No target channel specified and no default_channel configured.")
21
+ end
22
+
23
+ memoize def target
24
+ if supplied_target.start_with?("@")
25
+ name = supplied_target[1..]
26
+ client.users.find(display_name: name)&.id ||
27
+ raise(UserNotFoundError, "User with display name '#{name}' was not found.")
28
+ else
29
+ supplied_target
30
+ end
31
+ end
32
+
33
+ MAX_RETRIES = 2
34
+
35
+ memoize def api_response
36
+ if configuration.backoff
37
+ with_rate_limit_backoff { call_api }
38
+ else
39
+ call_api
40
+ end
41
+ end
42
+
43
+ def call_api
44
+ slack_client.chat_postMessage(
45
+ channel: target,
46
+ blocks: content_data,
47
+ thread_ts: thread_ts,
48
+ username: configuration.bot_name
49
+ )
50
+ end
51
+
52
+ def with_rate_limit_backoff
53
+ retries = 0
54
+ begin
55
+ yield
56
+ rescue Slack::Web::Api::Errors::TooManyRequestsError => e
57
+ raise if retries >= MAX_RETRIES
58
+ retries += 1
59
+ sleep(e.retry_after)
60
+ retry
61
+ end
62
+ end
63
+
64
+ attr_reader :message, :to, :thread_ts, :client
65
+ def_delegators :client, :configuration, :slack_client
66
+ def_delegators :message, :content
67
+ def_delegators :configuration, :look_up_users?
68
+
69
+ memoize def raw_content_data = content.as_json
70
+
71
+ memoize def converter = MessageConverter.new(raw_content_data, client:)
72
+
73
+ memoize def content_data = look_up_users? ? converter.convert : raw_content_data
74
+ end
75
+ end
@@ -12,11 +12,33 @@ module SlackLine
12
12
  attr_reader :content, :priorly, :response
13
13
  def_delegators :response, :ts, :channel
14
14
 
15
+ def thread_ts = response.thread_ts || ts
16
+
15
17
  def inspect = "#<#{self.class} channel=#{channel.inspect} ts=#{ts.inspect}>"
16
18
 
19
+ def as_json
20
+ {"type" => "message", "ts" => ts, "channel" => channel,
21
+ "thread_ts" => response.thread_ts, "content" => content, "priorly" => priorly}
22
+ end
23
+
24
+ def self.from_json(data, client:)
25
+ raise ArgumentError, "Expected type 'message', got #{data["type"].inspect}" unless data["type"] == "message"
26
+
27
+ response_hash = {ts: data["ts"], channel: data["channel"]}
28
+ response_hash[:thread_ts] = data["thread_ts"] if data["thread_ts"]
29
+ response = Slack::Messages::Message.new(response_hash)
30
+ new(response:, client:, content: data["content"], priorly: data["priorly"])
31
+ end
32
+
33
+ def append(*text_or_blocks, &dsl_block)
34
+ appended = Thread.new(*text_or_blocks, client:, &dsl_block)
35
+ new_sent = appended.messages.map { |m| m.post(to: channel, thread_ts:) }
36
+ SentThread.new(self, *new_sent)
37
+ end
38
+
17
39
  def update(*text_or_blocks, &dsl_block)
18
- updated_message = Message.new(*text_or_blocks, client:, &dsl_block)
19
- new_content = updated_message.content.as_json
40
+ replacement = replacement_message(*text_or_blocks, &dsl_block)
41
+ new_content = replacement.content.as_json
20
42
  response = slack_client.chat_update(channel:, ts:, blocks: new_content)
21
43
  SentMessage.new(content: new_content, priorly: content, response:, client:)
22
44
  end
@@ -25,5 +47,11 @@ module SlackLine
25
47
 
26
48
  attr_reader :client
27
49
  def_delegators :client, :slack_client
50
+
51
+ def replacement_message(*text_or_blocks, &dsl_block)
52
+ return text_or_blocks.first if text_or_blocks.size == 1 && text_or_blocks.first.is_a?(Message)
53
+
54
+ Message.new(*text_or_blocks, client:, &dsl_block)
55
+ end
28
56
  end
29
57
  end
@@ -13,6 +13,21 @@ module SlackLine
13
13
  def_delegators :first, :channel, :ts
14
14
  alias_method :thread_ts, :ts
15
15
 
16
+ def append(*text_or_blocks, &dsl_block)
17
+ extended = first.append(*text_or_blocks, &dsl_block)
18
+ SentThread.new(*sent_messages, *extended.sent_messages[1..])
19
+ end
20
+
16
21
  def inspect = "#<#{self.class} channel=#{channel.inspect} size=#{size} thread_ts=#{thread_ts.inspect}>"
22
+
23
+ def as_json
24
+ {"type" => "thread", "messages" => sent_messages.map(&:as_json)}
25
+ end
26
+
27
+ def self.from_json(data, client:)
28
+ raise ArgumentError, "Expected type 'thread', got #{data["type"].inspect}" unless data["type"] == "thread"
29
+
30
+ new(*data["messages"].map { |m| SentMessage.from_json(m, client:) })
31
+ end
17
32
  end
18
33
  end
@@ -20,12 +20,11 @@ module SlackLine
20
20
  memoize def builder_urls = messages.map(&:builder_url)
21
21
 
22
22
  def post(to: nil)
23
- target = to || client.configuration.default_channel || raise(ConfigurationError, "No target channel specified and no default_channel configured.")
24
23
  sent_messages = []
25
24
  thread_ts = nil
26
25
 
27
26
  messages.each do |message|
28
- sent = message.post(to: target, thread_ts:)
27
+ sent = message.post(to:, thread_ts:)
29
28
  thread_ts ||= sent.ts
30
29
  sent_messages << sent
31
30
  end
@@ -0,0 +1,44 @@
1
+ module SlackLine
2
+ class Users
3
+ include Memoization
4
+
5
+ def initialize(slack_client:)
6
+ @slack_client = slack_client
7
+ end
8
+
9
+ memoize def all = all_users.reject(&:deleted).reject(&:is_bot)
10
+
11
+ def find(display_name:)
12
+ users_by_display_name[display_name.downcase]
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :slack_client
18
+
19
+ def fetch_page(cursor: nil)
20
+ params = {limit: 200}
21
+ params[:cursor] = cursor if cursor
22
+
23
+ response = slack_client.users_list(params)
24
+ users = response.members || []
25
+ next_cursor = response.dig("response_metadata", "next_cursor")
26
+
27
+ [users, next_cursor]
28
+ end
29
+
30
+ memoize def all_users
31
+ all_users, cursor = fetch_page
32
+
33
+ while cursor
34
+ users, cursor = fetch_page(cursor:)
35
+ all_users.concat(users)
36
+ break if cursor.nil? || cursor.empty?
37
+ end
38
+
39
+ all_users
40
+ end
41
+
42
+ memoize def users_by_display_name = all.map { |u| [u.profile.display_name.downcase, u] }.to_h
43
+ end
44
+ end
@@ -1,3 +1,3 @@
1
1
  module SlackLine
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "1.0".freeze
3
3
  end
data/lib/slack_line.rb CHANGED
@@ -10,6 +10,7 @@ module SlackLine
10
10
  Error = Class.new(StandardError)
11
11
  ConfigurationError = Class.new(Error)
12
12
  PostMessageError = Class.new(Error)
13
+ UserNotFoundError = Class.new(Error)
13
14
 
14
15
  class << self
15
16
  extend Forwardable
@@ -24,6 +25,14 @@ module SlackLine
24
25
  memoize def client = Client.new(configuration)
25
26
 
26
27
  def_delegators(:client, :message, :thread)
28
+
29
+ TYPES = {"message" => ->(data, client:) { SentMessage.from_json(data, client:) },
30
+ "thread" => ->(data, client:) { SentThread.from_json(data, client:) }}.freeze
31
+
32
+ def from_json(data, client:)
33
+ loader = TYPES[data["type"]] or raise(ArgumentError, "Unknown type #{data["type"].inspect}")
34
+ loader.call(data, client:)
35
+ end
27
36
  end
28
37
  end
29
38
 
data/slack_line.gemspec CHANGED
@@ -25,11 +25,12 @@ Gem::Specification.new do |spec|
25
25
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
26
  `git ls-files -z`
27
27
  .split("\x0")
28
- .reject { |f| f.start_with?("spec") }
28
+ .reject { |f| f.start_with?("spec", "bin/qa") }
29
29
  end
30
30
  spec.executables = Dir.chdir(File.expand_path(__dir__)) do
31
31
  `git ls-files -z bin/`
32
32
  .split("\x0")
33
+ .reject { |f| f.start_with?("bin/qa") }
33
34
  .map { |path| path.sub(/^bin\//, "") }
34
35
  end
35
36
 
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: 0.1.0
4
+ version: '1.0'
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-01-31 00:00:00.000000000 Z
11
+ date: 2026-03-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -191,16 +191,23 @@ files:
191
191
  - bin/slack_line_message
192
192
  - bin/slack_line_thread
193
193
  - lib/slack_line.rb
194
+ - lib/slack_line/cli.rb
195
+ - lib/slack_line/cli/slack_line_message.rb
196
+ - lib/slack_line/cli/slack_line_thread.rb
194
197
  - lib/slack_line/client.rb
195
198
  - lib/slack_line/configuration.rb
199
+ - lib/slack_line/groups.rb
196
200
  - lib/slack_line/memoization.rb
197
201
  - lib/slack_line/message.rb
198
202
  - lib/slack_line/message_context.rb
203
+ - lib/slack_line/message_converter.rb
204
+ - lib/slack_line/message_sender.rb
199
205
  - lib/slack_line/section_context.rb
200
206
  - lib/slack_line/sent_message.rb
201
207
  - lib/slack_line/sent_thread.rb
202
208
  - lib/slack_line/thread.rb
203
209
  - lib/slack_line/thread_context.rb
210
+ - lib/slack_line/users.rb
204
211
  - lib/slack_line/version.rb
205
212
  - slack_line.gemspec
206
213
  homepage: https://github.com/nevinera/slack_line