slack_line 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 61c279b95b271ef984af73457248e82ab75a576aa46820bd59cacd8c8f6c8042
4
+ data.tar.gz: 57bc942d244ef3b45d95344ff0ab5bffb4412bc840016d2bf8e55450e1600742
5
+ SHA512:
6
+ metadata.gz: 8dd4210ffac87ab836e38902287098458dc0368d785f22c4a70221cc949cef70a219c0a96ed2adb37098d634e1c98899d1b59719cf41cfd7a248fe4259b12956
7
+ data.tar.gz: 20e53e6fa522453b25513f4ddfb503b74a7e1f31153837d7fb1dacfe9c89db19b4e5a2b29839ed24d5ef999da6b8fb16bed94a2fd09c76825afa41a997c29af5
@@ -0,0 +1,33 @@
1
+ name: Linters
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ Linting:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+
11
+ - name: Set up ruby
12
+ uses: ruby/setup-ruby@v1
13
+ with:
14
+ ruby-version: 3.3
15
+
16
+ - name: Cache gems
17
+ uses: actions/cache@v3
18
+ with: path: vendor/bundle
19
+ key: ${{ runner.os }}-linters-${{ hashFiles('Gemfile.lock') }}
20
+ restore-keys:
21
+ ${{ runner.os }}-linters-
22
+
23
+ - name: Install gems
24
+ run: bundle install --jobs 4 --retry 3
25
+
26
+ - name: Run StandardRB
27
+ run: bundle exec standardrb
28
+
29
+ - name: Run Rubocop (complexity checks)
30
+ run: bundle exec rubocop --parallel
31
+
32
+ - name: Run Markdownlint
33
+ run: bundle exec mdl .
@@ -0,0 +1,33 @@
1
+ name: RSpec
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ RSpec:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ fail-fast: false
10
+ matrix:
11
+ ruby-version: ['3.2', '3.3', '3.4', 'head']
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: ${{ matrix.ruby-version }}
20
+
21
+ - name: Cache gems
22
+ uses: actions/cache@v3
23
+ with:
24
+ path: vendor/bundle
25
+ key: ${{ runner.os }}-rspec-${{ matrix.ruby-version }}-${{ hashFiles('Gemfile.lock') }}
26
+ restore-keys:
27
+ ${{ runner.os }}-rspec-${{ matrix.ruby-version }}-
28
+
29
+ - name: Install gems
30
+ run: bundle install --jobs 4 --retry 3
31
+
32
+ - name: Run RSpec
33
+ run: SIMPLECOV=true bundle exec rspec
data/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ .ruby-version
2
+ .ruby-gemset
3
+ Gemfile.lock
4
+ *.gem
5
+ coverage/
6
+ tmp/*
7
+ .DS_Store
data/.mdl_rules.rb ADDED
@@ -0,0 +1,2 @@
1
+ all
2
+ rule "MD013", ignore_code_blocks: true
data/.mdlrc ADDED
@@ -0,0 +1,2 @@
1
+ style File.expand_path("../.mdl_rules.rb", __FILE__)
2
+ git_recurse true
@@ -0,0 +1,7 @@
1
+ ---
2
+ default_tools: ["standardrb", "rubocop", "markdown_lint", "rspec"]
3
+ executor: concurrent
4
+ comparison_branch: origin/main
5
+ logging: light
6
+ changed_files: false
7
+ filter_messages: false
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,21 @@
1
+ ---
2
+ AllCops:
3
+ SuggestExtensions: false
4
+ DisabledByDefault: true
5
+
6
+ Metrics/AbcSize:
7
+ Max: 15
8
+ Metrics/CyclomaticComplexity:
9
+ Max: 8
10
+ Metrics/PerceivedComplexity:
11
+ Max: 7
12
+
13
+ Metrics/ClassLength:
14
+ CountComments: false
15
+ Max: 150
16
+ Metrics/MethodLength:
17
+ CountComments: false
18
+ Max: 15
19
+ Metrics/ParameterLists:
20
+ Max: 5
21
+ CountKeywordArgs: true
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ ---
2
+ format: progress
3
+ ruby_version: 3.2
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://gem.coop"
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # SlackLine
2
+
3
+ This is a ruby gem supporting easy construction, sending, editing, and
4
+ threading of messages and threads.
5
+
6
+ ## Usage
7
+
8
+ Are you ready? Because this is going to be _so easy_.
9
+
10
+ ```ruby
11
+ # Send a simple message directly
12
+ sent_message = SlackLine.message("Something happened!", to: "#general").post
13
+
14
+ # Construct a more complex message, then send it
15
+ msg = SlackLine.message do
16
+ section do
17
+ context "Don't worry! If this message surprises you, context @foobar"
18
+ text "A thing has happened"
19
+ text "Yeah, it happened for sure"
20
+ link "How bad?", problems_path(problem.id)
21
+ end
22
+
23
+ text "More details.."
24
+ end
25
+ sent_message = msg.post(to: "#general")
26
+
27
+ # Send a _thread_ of messages (strings generate simple messages)
28
+ sent_thread = SlackLine.thread("First text", "Second text", msg, to: "#general").post
29
+
30
+ # You can also build them inline via dsl
31
+ sent_thread = SlackLine.thread(to: "@dm_recipient") do
32
+ message do
33
+ context "yeah"
34
+ text "That's right"
35
+ end
36
+
37
+ message(already_built_message)
38
+ message "this makes a basic message directly"
39
+ end.post
40
+ ```
41
+
42
+ And then once you've sent a message or thread, you'll have a SentMessage
43
+ or SentThread object (which is basically an Array of SentMessages). You
44
+ can call `SentMessage#update` on any of those messages to edit them
45
+ after the fact - this is especially useful for keeping a message in a
46
+ public channel updated with the state of the process it's linking to.
47
+
48
+ `update` accepts a String, a Message, or a block _defining_ a message.
49
+ To update a SentThread, you'll need to choose message:
50
+
51
+ ```ruby
52
+ sent_message.update "Edit: never mind, false alarm"
53
+ sent_thread.first.update "Problem Resolved!"
54
+ sent_thread.detect { |m| m =~ /Not yet safe/ }&.update do
55
+ text "it's safe now"
56
+ end
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ The only required setup is an OAuth token - each of these options can
62
+ be set via ENV or `SlackLine.configure`:
63
+
64
+ * `slack_token` or `SLACK_LINE_SLACK_TOKEN` (required) - this
65
+ allows the library to send messages and make API requests.
66
+ * `look_up_users` or `SLACK_LINE_LOOK_UP_USERS` (default false) - if
67
+ your workspace refuses to turn `@somebody` mentions into links or
68
+ notifications, you can set this and we'll parse them out, then use
69
+ the slack API to map them to user/group IDs.
70
+ * `bot_name` or `SLACK_LINE_BOT_NAME` - what to call the bot that's
71
+ posting (in slack). The default is to use its default name.
72
+ * `default_channel` or `SLACK_LINE_DEFAULT_CHANNEL` - a target name
73
+ (either a channel with the leading pound-sign, or a user's handle
74
+ with a leading at-sign). When not supplied, all `send` calls are
75
+ required to specify a target instead.
76
+ * `per_message_delay` or `SLACK_LINE_PER_MESSAGE_DELAY` is a float,
77
+ defaulting to 0.0. SlackLine will `sleep` for that duration after
78
+ each message is posted, to allow you to avoid hitting rate-limits
79
+ from posting many messages in a row.
80
+ * `per_thread_delay` or `SLACK_LINE_PER_THREAD_DELAY` is a float as
81
+ well - SlackLine will `sleep` for this duration after each _thread_
82
+ is posted, and after each non-thread message is posted.
83
+
84
+ You can just set those via the environment variables, but you can also
85
+ set them on the singleton configuration object:
86
+
87
+ ```ruby
88
+ SlackLine.configure do |config|
89
+ config.slack_token = ENV["SLACK_TOKEN"]
90
+ config.look_up_users = true
91
+ config.bot_name = "CI Bot"
92
+ config.default_channel = "#ci-flow"
93
+ config.per_message_delay = 0.2
94
+ config.per_thread_delay = 2.0
95
+ end
96
+ ```
97
+
98
+ ## Multiple Configurations
99
+
100
+ If you're working in a context where you need to support multiple
101
+ SlackLine configurations, don't worry! The singleton central config is
102
+ what the singleton central Client uses (that's what all of the top-level
103
+ SlackLine methods dispatch to), but you can construct additional clients
104
+ with their own configs easily:
105
+
106
+ ```ruby
107
+ Slackline.configure do |config|
108
+ config.slack_token = ENV["TOKEN"]
109
+ config.default_channel = "#general"
110
+ config.bot_name = "FooBot"
111
+ end
112
+
113
+ # Now SlackLine.message (et al) will use SlackLine.client,
114
+ # configured as above.
115
+
116
+ BAR_SLACK = SlackLine::Client.new(default_channel: "#team-bar", bot_name: "BarBot")
117
+
118
+ # And now you can call those methods on `BAR_SLACK` to use a different
119
+ # default channel and name
120
+
121
+ BAR_SLACK.thread("Message 1", "Message 2").post
122
+ BAR_SLACK.message("Message 3", to: "#bar-team-3").post
123
+ ```
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/slack_line"
4
+ require "optparse"
5
+ require "reline"
6
+
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)
49
+ end
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/slack_line"
4
+ require "optparse"
5
+ require "reline"
6
+
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
55
+ end
@@ -0,0 +1,19 @@
1
+ module SlackLine
2
+ class Client
3
+ include Memoization
4
+
5
+ def initialize(base_config = nil, **overrides)
6
+ @configuration = Configuration.new(base_config, **overrides)
7
+
8
+ raise ArgumentError, "slack_token is required" if @configuration.slack_token.nil?
9
+ end
10
+
11
+ attr_reader :configuration
12
+
13
+ memoize def slack_client = Slack::Web::Client.new(token: configuration.slack_token)
14
+
15
+ def message(*text_or_blocks, &dsl_block) = Message.new(*text_or_blocks, client: self, &dsl_block)
16
+
17
+ def thread(*messages, &dsl_block) = Thread.new(*messages, client: self, &dsl_block)
18
+ end
19
+ end
@@ -0,0 +1,54 @@
1
+ module SlackLine
2
+ class Configuration
3
+ attr_accessor :slack_token,
4
+ :look_up_users, :bot_name, :default_channel,
5
+ :per_message_delay, :per_thread_delay
6
+
7
+ DEFAULTS = {
8
+ slack_token: nil,
9
+ look_up_users: false,
10
+ bot_name: nil,
11
+ default_channel: nil,
12
+ per_message_delay: 0.0,
13
+ per_thread_delay: 0.0
14
+ }.freeze
15
+
16
+ def initialize(base_config = nil, **overrides)
17
+ @base_config = base_config
18
+ @overrides = overrides
19
+
20
+ @slack_token = cascade(:slack_token, "SLACK_LINE_SLACK_TOKEN", :string)
21
+ @look_up_users = cascade(:look_up_users, "SLACK_LINE_LOOK_UP_USERS", :boolean)
22
+ @bot_name = cascade(:bot_name, "SLACK_LINE_BOT_NAME", :string)
23
+ @default_channel = cascade(:default_channel, "SLACK_LINE_DEFAULT_CHANNEL", :string)
24
+ @per_message_delay = cascade(:per_message_delay, "SLACK_LINE_PER_MESSAGE_DELAY", :float)
25
+ @per_thread_delay = cascade(:per_thread_delay, "SLACK_LINE_PER_THREAD_DELAY", :float)
26
+ end
27
+
28
+ private
29
+
30
+ def cascade(key, env_name, env_type)
31
+ if @overrides&.key?(key)
32
+ @overrides[key]
33
+ elsif @base_config
34
+ @base_config.public_send(key)
35
+ elsif ENV.key?(env_name)
36
+ from_env(env_name, env_type)
37
+ else
38
+ DEFAULTS[key]
39
+ end
40
+ end
41
+
42
+ def from_env(env_name, env_type)
43
+ value = ENV.fetch(env_name)
44
+
45
+ if env_type == :boolean
46
+ %w[1 true yes].include?(value.downcase)
47
+ elsif env_type == :float
48
+ value.to_f
49
+ else
50
+ value
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,24 @@
1
+ module SlackLine
2
+ module Memoization
3
+ def self.included(base) = base.extend(ClassMethods)
4
+
5
+ module ClassMethods
6
+ def memoize(method_name)
7
+ original_method = instance_method(method_name)
8
+
9
+ define_method(method_name) do |*args|
10
+ raise(ArgumentError, "Cannot memoize methods that take arguments") if args.any?
11
+
12
+ @memoization_cache ||= {}
13
+
14
+ if @memoization_cache.key?(method_name)
15
+ @memoization_cache[method_name]
16
+ else
17
+ result = original_method.bind_call(self)
18
+ @memoization_cache[method_name] = result
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,74 @@
1
+ module SlackLine
2
+ class Message
3
+ extend Forwardable
4
+ include Memoization
5
+
6
+ def initialize(*text_or_blocks, client:, &dsl_block)
7
+ @text_or_blocks = text_or_blocks
8
+ @dsl_block = dsl_block
9
+ @client = client
10
+
11
+ validate!
12
+ end
13
+
14
+ memoize def content
15
+ if @dsl_block
16
+ MessageContext.new(&@dsl_block).content
17
+ elsif strings?(@text_or_blocks)
18
+ convert_multistring(*@text_or_blocks)
19
+ elsif blocks?(@text_or_blocks)
20
+ @text_or_blocks.first
21
+ end
22
+ end
23
+
24
+ # easier prototyping/verification. You can definitely construct illegal messages
25
+ # using the library in various ways, but if Slack's BlockKit Builder accepts it,
26
+ # it's probably right.
27
+ memoize def builder_url
28
+ blocks_json = {blocks: content_data}.to_json
29
+ escaped_json = CGI.escape(blocks_json)
30
+ "https://app.slack.com/block-kit-builder##{escaped_json}"
31
+ end
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
38
+
39
+ private
40
+
41
+ attr_reader :client
42
+ def_delegators :client, :slack_client, :configuration
43
+
44
+ def validate!
45
+ validate_xor!
46
+ validate_type!
47
+ end
48
+
49
+ def validate_xor!
50
+ raise(ArgumentError, "Provide either strings/Slack::BlockKit::Blocks, or a DSL block, not both.") if @dsl_block && @text_or_blocks.any?
51
+ raise(ArgumentError, "Provide either strings/Slack::BlockKit::Blocks, or a DSL block.") unless @dsl_block || @text_or_blocks.any?
52
+ end
53
+
54
+ def validate_type!
55
+ unless @text_or_blocks.empty? || blocks?(@text_or_blocks) || strings?(@text_or_blocks)
56
+ raise(ArgumentError, "Invalid content type: #{@text_or_blocks.class}")
57
+ end
58
+ end
59
+
60
+ def blocks?(obj) = obj.is_a?(Array) && obj.size == 1 && obj.first.is_a?(Slack::BlockKit::Blocks)
61
+
62
+ def strings?(obj) = obj.is_a?(Array) && obj.size > 0 && obj.all? { |item| item.is_a?(String) }
63
+
64
+ def convert_multistring(*strs)
65
+ Slack::BlockKit.blocks do |b|
66
+ strs.each do |str|
67
+ b.section { |s| s.mrkdwn(text: str) }
68
+ end
69
+ end
70
+ end
71
+
72
+ memoize def content_data = content.as_json
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ module SlackLine
2
+ class MessageContext
3
+ def initialize(&block)
4
+ @content = ::Slack::BlockKit.blocks do |b|
5
+ @in_progress_blocks = b
6
+ instance_exec(&block)
7
+ ensure
8
+ @in_progress_blocks = nil
9
+ end
10
+ end
11
+
12
+ attr_reader :content
13
+
14
+ def text(content, plain: false)
15
+ @in_progress_blocks.section do |s|
16
+ plain ? s.plain_text(text: content) : s.mrkdwn(text: content)
17
+ end
18
+ end
19
+
20
+ def section(&block) = SectionContext.new(@in_progress_blocks, &block).content
21
+
22
+ def context(content, plain: false)
23
+ @in_progress_blocks.context do |c|
24
+ plain ? c.plain_text(text: content) : c.mrkdwn(text: content)
25
+ end
26
+ end
27
+
28
+ def divider = @in_progress_blocks.divider
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ module SlackLine
2
+ class SectionContext
3
+ def initialize(parent_context, &block)
4
+ @content = parent_context.section do |s|
5
+ @in_progress_section = s
6
+ instance_exec(&block)
7
+ ensure
8
+ @in_progress_section = nil
9
+ end
10
+ end
11
+
12
+ attr_reader :content
13
+
14
+ def text(content, plain: false)
15
+ if plain
16
+ @in_progress_section.plain_text(text: content)
17
+ else
18
+ @in_progress_section.mrkdwn(text: content)
19
+ end
20
+ end
21
+
22
+ def link(text, url) = @in_progress_section.button(text: text, url: url, action_id: "link-button")
23
+ end
24
+ end
@@ -0,0 +1,29 @@
1
+ module SlackLine
2
+ class SentMessage
3
+ extend Forwardable
4
+
5
+ def initialize(response:, client:, content:, priorly: nil)
6
+ @content = content
7
+ @priorly = priorly
8
+ @response = response
9
+ @client = client
10
+ end
11
+
12
+ attr_reader :content, :priorly, :response
13
+ def_delegators :response, :ts, :channel
14
+
15
+ def inspect = "#<#{self.class} channel=#{channel.inspect} ts=#{ts.inspect}>"
16
+
17
+ 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
20
+ response = slack_client.chat_update(channel:, ts:, blocks: new_content)
21
+ SentMessage.new(content: new_content, priorly: content, response:, client:)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :client
27
+ def_delegators :client, :slack_client
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ module SlackLine
2
+ class SentThread
3
+ extend Forwardable
4
+ include Enumerable
5
+
6
+ def initialize(*sent_messages)
7
+ @sent_messages = sent_messages.freeze
8
+ end
9
+
10
+ attr_reader :sent_messages
11
+ alias_method :messages, :sent_messages
12
+ def_delegators :sent_messages, :each, :map, :size, :first, :last, :empty?
13
+ def_delegators :first, :channel, :ts
14
+ alias_method :thread_ts, :ts
15
+
16
+ def inspect = "#<#{self.class} channel=#{channel.inspect} size=#{size} thread_ts=#{thread_ts.inspect}>"
17
+ end
18
+ end
@@ -0,0 +1,73 @@
1
+ module SlackLine
2
+ class Thread
3
+ extend Forwardable
4
+ include Enumerable
5
+ include Memoization
6
+
7
+ def initialize(*supplied_messages, client:, &dsl_block)
8
+ @supplied_messages = supplied_messages
9
+ @dsl_block = dsl_block
10
+ @client = client
11
+
12
+ validate!
13
+ end
14
+
15
+ # an Array of SlackLine::Messages
16
+ memoize def messages = message_contents.map { |mc| convert_supplied_message(mc) }
17
+
18
+ def_delegators :messages, :each, :[], :length, :size, :empty?
19
+
20
+ memoize def builder_urls = messages.map(&:builder_url)
21
+
22
+ def post(to: nil)
23
+ target = to || client.configuration.default_channel || raise(ConfigurationError, "No target channel specified and no default_channel configured.")
24
+ sent_messages = []
25
+ thread_ts = nil
26
+
27
+ messages.each do |message|
28
+ sent = message.post(to: target, thread_ts:)
29
+ thread_ts ||= sent.ts
30
+ sent_messages << sent
31
+ end
32
+
33
+ SentThread.new(*sent_messages)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :client
39
+ def_delegators :client, :slack_client, :configuration
40
+
41
+ def validate!
42
+ validate_xor!
43
+ validate_types!
44
+ end
45
+
46
+ def validate_xor!
47
+ raise(ArgumentError, "Provide either texts/blocks/Messages or a DSL block, not both.") if @dsl_block && @supplied_messages.any?
48
+ raise(ArgumentError, "Provide either texts/blocks/Messages or a DSL block.") unless @dsl_block || @supplied_messages.any?
49
+ end
50
+
51
+ def validate_types!
52
+ @supplied_messages.each do |sm|
53
+ unless sm.is_a?(String) || sm.is_a?(Slack::BlockKit::Blocks) || sm.is_a?(Message)
54
+ raise(ArgumentError, "Invalid message type: #{sm.class}. Excepted a String, Slack::BlockKit::Blocks, or SlackLine::Message.")
55
+ end
56
+ end
57
+ end
58
+
59
+ memoize def dsl_contents = ThreadContext.new(&@dsl_block).contents
60
+
61
+ # produces an Array of (mixed) Strings, Slack::BlockKit::Blocks, and SlackLine::Messages
62
+ # (ThreadContext will produce Strings and Blocks)
63
+ memoize def message_contents = @dsl_block ? dsl_contents : @supplied_messages
64
+
65
+ def convert_supplied_message(sm)
66
+ if sm.is_a?(String) || sm.is_a?(Slack::BlockKit::Blocks)
67
+ Message.new(sm, client:)
68
+ elsif sm.is_a?(Message)
69
+ sm
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,35 @@
1
+ module SlackLine
2
+ class ThreadContext
3
+ def initialize(&block)
4
+ @contents = []
5
+ instance_exec(&block)
6
+ end
7
+
8
+ attr_reader :contents
9
+
10
+ def text(text)
11
+ fail(ArgumentError, "Text must be a String.") unless text.is_a?(String)
12
+
13
+ @contents << text
14
+ end
15
+
16
+ # supplied should be a String, Slack::BlockKit::Blocks, or an already constructed SlackLine::Message
17
+ def message(supplied = nil, &msg_block)
18
+ if (supplied && msg_block) || (!supplied && !msg_block)
19
+ fail(ArgumentError, "Provide either a supplied message or a message block, not both.")
20
+ end
21
+ validate_supplied_message!(supplied)
22
+
23
+ @contents << (supplied || MessageContext.new(&msg_block).content)
24
+ end
25
+
26
+ private
27
+
28
+ def validate_supplied_message!(sm)
29
+ expected_types = [NilClass, String, Slack::BlockKit::Blocks, SlackLine::Message]
30
+ unless expected_types.any? { |t| sm.is_a?(t) }
31
+ fail(ArgumentError, "Invalid message type: #{sm.class}. Expected a String, Slack::BlockKit::Blocks, or SlackLine::Message.")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ module SlackLine
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/slack_line.rb ADDED
@@ -0,0 +1,31 @@
1
+ require "forwardable"
2
+ require "slack-ruby-block-kit"
3
+ require "slack-ruby-client"
4
+ require "json"
5
+ require "cgi"
6
+
7
+ require_relative "slack_line/memoization"
8
+
9
+ module SlackLine
10
+ Error = Class.new(StandardError)
11
+ ConfigurationError = Class.new(Error)
12
+ PostMessageError = Class.new(Error)
13
+
14
+ class << self
15
+ extend Forwardable
16
+ include Memoization
17
+
18
+ # The Singleton configuration object - used by the Singleton client,
19
+ # and as config defaults for other clients.
20
+ memoize def configuration = Configuration.new
21
+
22
+ def configure = yield(configuration)
23
+
24
+ memoize def client = Client.new(configuration)
25
+
26
+ def_delegators(:client, :message, :thread)
27
+ end
28
+ end
29
+
30
+ glob = File.expand_path("../slack_line/*.rb", __FILE__)
31
+ Dir.glob(glob).sort.each { |f| require(f) }
@@ -0,0 +1,48 @@
1
+ require_relative "lib/slack_line/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "slack_line"
5
+ spec.version = SlackLine::VERSION
6
+ spec.authors = ["Eric Mueller"]
7
+ spec.email = ["nevinera@gmail.com"]
8
+
9
+ spec.summary = "Build CLIs that are configured via args, file, and/or environment"
10
+ spec.description = <<~DESC
11
+ We've written code that merges/cascades default configuration, config-files,
12
+ environment variables, and cli-passed arguments _too many times_. This gem
13
+ intends to distill that into a configuration hash describing those controls
14
+ and relationships, so that users can supply values in multiple ways.
15
+ DESC
16
+ spec.homepage = "https://github.com/nevinera/slack_line"
17
+ spec.license = "MIT"
18
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
19
+
20
+ spec.metadata["homepage_uri"] = spec.homepage
21
+ spec.metadata["source_code_uri"] = spec.homepage
22
+
23
+ spec.require_paths = ["lib"]
24
+ spec.bindir = "bin"
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ `git ls-files -z`
27
+ .split("\x0")
28
+ .reject { |f| f.start_with?("spec") }
29
+ end
30
+ spec.executables = Dir.chdir(File.expand_path(__dir__)) do
31
+ `git ls-files -z bin/`
32
+ .split("\x0")
33
+ .map { |path| path.sub(/^bin\//, "") }
34
+ end
35
+
36
+ spec.add_development_dependency "rspec", "~> 3.13"
37
+ spec.add_development_dependency "rspec-its", "~> 1.3"
38
+ spec.add_development_dependency "rspec-collection_matchers", "~> 1.2.1"
39
+ spec.add_development_dependency "simplecov", "~> 0.22.0"
40
+ spec.add_development_dependency "quiet_quality"
41
+ spec.add_development_dependency "pry", "~> 0.14"
42
+ spec.add_development_dependency "standard", ">= 1.35.1"
43
+ spec.add_development_dependency "rubocop", ">= 1.62"
44
+ spec.add_development_dependency "mdl", "~> 0.12"
45
+
46
+ spec.add_dependency "slack-ruby-block-kit", "~> 0.23.0"
47
+ spec.add_dependency "slack-ruby-client", "~> 3.1.0"
48
+ end
metadata ADDED
@@ -0,0 +1,231 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slack_line
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Mueller
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-01-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-its
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec-collection_matchers
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 1.2.1
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.2.1
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.22.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.22.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: quiet_quality
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.14'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.14'
97
+ - !ruby/object:Gem::Dependency
98
+ name: standard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: 1.35.1
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 1.35.1
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '1.62'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '1.62'
125
+ - !ruby/object:Gem::Dependency
126
+ name: mdl
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.12'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.12'
139
+ - !ruby/object:Gem::Dependency
140
+ name: slack-ruby-block-kit
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: 0.23.0
146
+ type: :runtime
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: 0.23.0
153
+ - !ruby/object:Gem::Dependency
154
+ name: slack-ruby-client
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: 3.1.0
160
+ type: :runtime
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: 3.1.0
167
+ description: |
168
+ We've written code that merges/cascades default configuration, config-files,
169
+ environment variables, and cli-passed arguments _too many times_. This gem
170
+ intends to distill that into a configuration hash describing those controls
171
+ and relationships, so that users can supply values in multiple ways.
172
+ email:
173
+ - nevinera@gmail.com
174
+ executables:
175
+ - slack_line_message
176
+ - slack_line_thread
177
+ extensions: []
178
+ extra_rdoc_files: []
179
+ files:
180
+ - ".github/workflows/linters.yml"
181
+ - ".github/workflows/rspec.yml"
182
+ - ".gitignore"
183
+ - ".mdl_rules.rb"
184
+ - ".mdlrc"
185
+ - ".quiet_quality.yml"
186
+ - ".rspec"
187
+ - ".rubocop.yml"
188
+ - ".standard.yml"
189
+ - Gemfile
190
+ - README.md
191
+ - bin/slack_line_message
192
+ - bin/slack_line_thread
193
+ - lib/slack_line.rb
194
+ - lib/slack_line/client.rb
195
+ - lib/slack_line/configuration.rb
196
+ - lib/slack_line/memoization.rb
197
+ - lib/slack_line/message.rb
198
+ - lib/slack_line/message_context.rb
199
+ - lib/slack_line/section_context.rb
200
+ - lib/slack_line/sent_message.rb
201
+ - lib/slack_line/sent_thread.rb
202
+ - lib/slack_line/thread.rb
203
+ - lib/slack_line/thread_context.rb
204
+ - lib/slack_line/version.rb
205
+ - slack_line.gemspec
206
+ homepage: https://github.com/nevinera/slack_line
207
+ licenses:
208
+ - MIT
209
+ metadata:
210
+ homepage_uri: https://github.com/nevinera/slack_line
211
+ source_code_uri: https://github.com/nevinera/slack_line
212
+ post_install_message:
213
+ rdoc_options: []
214
+ require_paths:
215
+ - lib
216
+ required_ruby_version: !ruby/object:Gem::Requirement
217
+ requirements:
218
+ - - ">="
219
+ - !ruby/object:Gem::Version
220
+ version: 3.2.0
221
+ required_rubygems_version: !ruby/object:Gem::Requirement
222
+ requirements:
223
+ - - ">="
224
+ - !ruby/object:Gem::Version
225
+ version: '0'
226
+ requirements: []
227
+ rubygems_version: 3.5.22
228
+ signing_key:
229
+ specification_version: 4
230
+ summary: Build CLIs that are configured via args, file, and/or environment
231
+ test_files: []