grape-slack-bot 1.0.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: 3aed9857efe4c5d25d1028d27e2011dfb9465c95a55fbffc1c494fc3d7052625
4
+ data.tar.gz: d7b42e0134acc14f0d0efaeea30900688c35e38aeff5a6dfd9e898b6f343131f
5
+ SHA512:
6
+ metadata.gz: 3bb6da8f98fc125e615216325a2779361ad2dc8444f14f98dc24240185e26049802cbe357ddee2d0d5eca8e1172d9e45fc0f447973f176a8f791450ed9eb378f
7
+ data.tar.gz: 1ed2fdaf78f7f71e4eb454fe7fa98559f6e59ea296cd6c5b9c17042115a7297a33e2e16012352539e6f835fac93c02ec87bdcb04a901a1f782f2ed773193b77c
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # 1.0.0
2
+
3
+ * Initial version
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 amkisko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,498 @@
1
+ # grape-slack-bot.rb
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/grape-slack-bot.svg)](https://badge.fury.io/rb/grape-slack-bot) [![Test Status](https://github.com/amkisko/grape-slack-bot.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/grape-slack-bot.rb/actions/workflows/test.yml)
4
+
5
+ Extensible Slack bot implementation gem for [ruby-grape](https://github.com/ruby-grape/grape)
6
+
7
+ Sponsored by [Kisko Labs](https://www.kiskolabs.com).
8
+
9
+ ## Install
10
+
11
+ Using Bundler:
12
+ ```sh
13
+ bundle add grape-slack-bot
14
+ ```
15
+
16
+ Using RubyGems:
17
+ ```sh
18
+ gem install grape-slack-bot
19
+ ```
20
+
21
+ ## Gemfile
22
+
23
+ ```ruby
24
+ gem 'grape-slack-bot'
25
+ ```
26
+
27
+ ## Gem modules and classes
28
+
29
+ `SlackBot` is the main module that contains all the classes and modules.
30
+
31
+ ## Concepts
32
+
33
+ ### Slash command
34
+
35
+ Slash command is a command that is triggered by user in Slack chat using `/` prefix.
36
+
37
+ Characteristics:
38
+ - Can have multiple URL endpoints (later called `url_token`, e.g. `/api/slack/commands/game`)
39
+ - Starts with `/` and is followed by command name (e.g. `/game`, called `token`)
40
+ - Can have multiple argument commands (e.g. `/game start`, called `token`)
41
+ - Can have multiple arguments (e.g. `/game start password=P@5sW0Rd`, called `args`)
42
+ - Can send message to chat
43
+ - Can open interactive component with callback identifier
44
+ - Can trigger event in background
45
+
46
+ References:
47
+ - [slash_command.rb](lib/slack_bot/slash_command.rb)
48
+ - [Slash command documentation](https://api.slack.com/interactivity/slash-commands)
49
+
50
+ ### Interactive component
51
+
52
+ Interactive component is a component that is requested to be opened by bot app for the user in Slack application.
53
+
54
+ Characteristics:
55
+ - Can be associated with slash command
56
+
57
+ References:
58
+ - [interaction.rb](lib/slack_bot/interaction.rb)
59
+ - [Interactive components documentation](https://api.slack.com/interactivity/handling)
60
+
61
+ ### Event
62
+
63
+ Event is a notification that is sent to bot app when something happens in Slack.
64
+
65
+ References:
66
+ - [event.rb](lib/slack_bot/event.rb)
67
+ - [Event documentation](https://api.slack.com/events-api)
68
+
69
+ ### View
70
+
71
+ View is a class that has logic for rendering internals of message or modal or any other user interface component.
72
+
73
+ Characteristics:
74
+ - Can be associated with slash command, interactive component or event for using ready-made methods like `open_modal`, `update_modal` or `publish_view`
75
+
76
+ References:
77
+ - [view.rb](lib/slack_bot/view.rb)
78
+ - [App home documentation](https://api.slack.com/surfaces/app-home)
79
+ - [Messages documentation](https://api.slack.com/messaging)
80
+ - [Modals documentation](https://api.slack.com/surfaces/modals)
81
+
82
+ ### Block
83
+
84
+ Block is an object that is used to render user interface elements in Slack.
85
+
86
+ References:
87
+ - [Block kit documentation](https://api.slack.com/block-kit)
88
+
89
+ ### Callback
90
+
91
+ Callback is a class for managing interactive component state and handling interactive component actions.
92
+
93
+ Example uses `Rails.cache` for storing interactive component state, use `CallbackStorage` for building custom storage class as a base.
94
+
95
+ References:
96
+ - [callback.rb](lib/slack_bot/callback.rb)
97
+ - [callback_storage.rb](lib/slack_bot/callback_storage.rb)
98
+
99
+ ### Arguments
100
+
101
+ Class for handling slash command and interactive element values as queries.
102
+
103
+ Gem implementation uses `Rack::Utils` for parsing and building query strings.
104
+
105
+ References:
106
+ - [args.rb](lib/slack_bot/args.rb)
107
+
108
+ ### Pager
109
+
110
+ Own implementation of pagination that is relying on [Arguments](#arguments) and [ActiveRecord](https://guides.rubyonrails.org/active_record_querying.html).
111
+
112
+ References:
113
+ - [pager.rb](lib/slack_bot/pager.rb)
114
+
115
+ ## Specification
116
+
117
+ [x] Create any amount of endpoints that will handle Slack calls
118
+ [x] Create multiple instances of bots and configure them separately or use the same configuration for all bots
119
+ [x] Define and reuse slash command handlers for Slack slash commands
120
+ [x] Define interactive component handlers for Slack interactive components
121
+ [x] Define and reuse views for slash commands, interactive components and events
122
+ [x] Define event handlers for Slack events
123
+ [x] Define menu options handlers for Slack menu options
124
+ [x] Store interactive component state in cache for usage in other handlers
125
+ [x] Access current user session and user from any handler
126
+ [x] Extend API endpoint with custom hooks and helpers within [grape specification](https://github.com/ruby-grape/grape)
127
+ [x] Supports Slack signature verification
128
+ [ ] Supports Slack socket mode (?)
129
+ [ ] Supports Slack token rotation
130
+
131
+ ## Usage with grape
132
+
133
+ Create `app/api/slack_bot_api.rb`, it will contain bot configuration and endpoints setup:
134
+
135
+ ```ruby
136
+ SlackBot::DevConsole.enabled = Rails.env.development?
137
+ SlackBot::Config.configure do
138
+ callback_storage Rails.cache
139
+ callback_user_finder ->(id) { User.active.find_by(id: id) }
140
+
141
+ # TODO: Register event handlers
142
+ event :app_home_opened, MySlackBot::AppHomeOpenedEvent
143
+
144
+ # TODO: Register slash command handlers
145
+ slash_command_endpoint :game, MySlackBot::Game::MenuCommand do
146
+ command :start, MySlackBot::Game::StartCommand
147
+ end
148
+ end
149
+
150
+ class SlackBotApi < Grape::API
151
+ include SlackBot::GrapeExtension
152
+
153
+ helpers do
154
+ def config
155
+ SlackBot::Config.current_instance
156
+ end
157
+
158
+ def resolve_user_session(team_id, user_id)
159
+ uid = OmniAuth::Strategies::SlackOpenid.generate_uid(team_id, user_id)
160
+ UserSession.find_by(uid: uid, provider: UserSession.slack_openid_provider)
161
+ end
162
+
163
+ def current_user_session
164
+ # NOTE: fetch_team_id and fetch_user_id are provided by SlackBot::Grape::ApiExtension
165
+ @current_user_session ||=
166
+ resolve_user_session(fetch_team_id, fetch_user_id)
167
+ end
168
+
169
+ def current_user_ip
170
+ request.env["action_dispatch.remote_ip"].to_s
171
+ end
172
+
173
+ def current_user
174
+ @current_user ||= current_user_session&.user
175
+ end
176
+ end
177
+ end
178
+ ```
179
+
180
+ In routes file `config/routes.rb` mount the API:
181
+
182
+ ```ruby
183
+ mount SlackBotApi => "/api/slack"
184
+ ```
185
+
186
+ ## Slack bot manifest
187
+
188
+ You can use this manifest as a template for your Slack app configuration:
189
+
190
+ ```yaml
191
+ display_information:
192
+ name: Example
193
+ description: Example bot
194
+ background_color: "#000000"
195
+ features:
196
+ bot_user:
197
+ display_name: Example
198
+ always_online: true
199
+ slash_commands:
200
+ - command: /game
201
+ url: https://example.com/api/slack/commands/game
202
+ description: The game
203
+ should_escape: false
204
+ oauth_config:
205
+ redirect_urls:
206
+ - https://example.com/user/auth/slack_openid/callback
207
+ scopes:
208
+ bot:
209
+ - incoming-webhook
210
+ - app_mentions:read
211
+ - chat:write
212
+ - users:read
213
+ - users:read.email
214
+ - im:read
215
+ - im:write
216
+ - im:history
217
+ - channels:read
218
+ - groups:read
219
+ - mpim:read
220
+ - reactions:read
221
+ - commands
222
+ settings:
223
+ event_subscriptions:
224
+ request_url: https://example.com/api/slack/events
225
+ bot_events:
226
+ - app_home_opened
227
+ - app_mention
228
+ - im_history_changed
229
+ - member_joined_channel
230
+ - member_left_channel
231
+ - message.im
232
+ - profile_opened
233
+ - reaction_added
234
+ - reaction_removed
235
+ interactivity:
236
+ is_enabled: true
237
+ request_url: https://example.com/api/slack/interactions
238
+ message_menu_options_url: https://example.com/api/slack/menu_options
239
+ org_deploy_enabled: false
240
+ socket_mode_enabled: false
241
+ token_rotation_enabled: false
242
+ ```
243
+
244
+ ## Command example
245
+
246
+ ```ruby
247
+ module MySlackBot::Game
248
+ class MenuCommand < SlackBot::Command
249
+ interaction MySlackBot::Game::MenuInteraction
250
+ view MySlackBot::Game::MenuView
251
+ def call
252
+ open_modal :index_modal
253
+ end
254
+ end
255
+ class StartCommand < SlackBot::Command
256
+ interaction MySlackBot::Game::StartInteraction
257
+ view MySlackBot::Game::StartView
258
+ def call
259
+ open_modal :index_modal
260
+ end
261
+ end
262
+ end
263
+
264
+ ```
265
+
266
+ ## Interaction example
267
+
268
+ ```ruby
269
+ module MySlackBot::Game
270
+ class StartInteraction < SlackBot::Interaction
271
+ view MySlackBot::Game::StartView
272
+ def call
273
+ return if interaction_type != "block_actions"
274
+
275
+ update_callback_args do |action|
276
+ action_id = action["action_id"]
277
+ action_type = action["type"]
278
+ case action_type
279
+ when "static_select"
280
+ if action_id == "games_users_list_select_user"
281
+ callback.args[:user_id] = action["selected_option"]["value"]
282
+ end
283
+ else
284
+ callback.args.raw_args = action["value"]
285
+ end
286
+ end
287
+
288
+ update_modal :index_modal
289
+ end
290
+ end
291
+ end
292
+ ```
293
+
294
+ ## View example
295
+
296
+ Modal view example:
297
+
298
+ ```ruby
299
+ module MySlackBot::Game
300
+ class MenuView < SlackBot::View
301
+ def index_modal
302
+ blocks = []
303
+
304
+ blocks << {
305
+ type: "section",
306
+ block_id: "section_help_list",
307
+ text: {
308
+ type: "mrkdwn",
309
+ text: "#{command} start - Start the game"
310
+ }
311
+ }
312
+
313
+ cursor = Game.active
314
+ pager = paginate(cursor)
315
+
316
+ blocks << {
317
+ type: "section",
318
+ block_id: "section_games_list",
319
+ text: {
320
+ type: "mrkdwn",
321
+ text: "*Games*"
322
+ }
323
+ }
324
+
325
+ if pager.cursor.present?
326
+ pager.cursor.find_each do |game|
327
+ blocks << {
328
+ type: "section",
329
+ block_id: "section_game_#{game.id}",
330
+ text: {
331
+ type: "mrkdwn",
332
+ text: "#{game.name}"
333
+ },
334
+ accessory: {
335
+ type: "button",
336
+ action_id: "games_users_list_join_game",
337
+ text: {
338
+ type: "plain_text",
339
+ text: "Join"
340
+ },
341
+ value: args.merge(game_id: game.id).to_s
342
+ }
343
+ }
344
+ end
345
+ else
346
+ blocks << {
347
+ type: "section",
348
+ block_id: "section_games_list_empty",
349
+ text: {
350
+ type: "mrkdwn",
351
+ text: "No active games"
352
+ }
353
+ }
354
+ end
355
+
356
+ if pager.pages_count > 1
357
+ pager_elements = []
358
+ if pager.page > 1
359
+ pager_elements << {
360
+ type: "button",
361
+ action_id: "games_list_previous_page",
362
+ text: {
363
+ type: "plain_text",
364
+ text: ":arrow_left: Previous page"
365
+ },
366
+ value: args.merge(page: pager.page - 1).to_s
367
+ }
368
+ end
369
+ if pager.page < pager.pages_count
370
+ pager_elements << {
371
+ type: "button",
372
+ action_id: "games_list_next_page",
373
+ text: {
374
+ type: "plain_text",
375
+ text: "Next page :arrow_right:"
376
+ },
377
+ value: args.merge(page: pager.page + 1).to_s
378
+ }
379
+ end
380
+ if pager_elements.present?
381
+ blocks << {
382
+ type: "actions",
383
+ elements: pager_elements
384
+ }
385
+ end
386
+ end
387
+
388
+ {
389
+ title: {
390
+ type: "plain_text",
391
+ text: "Example help"
392
+ },
393
+ blocks: blocks
394
+ }
395
+ end
396
+ end
397
+ end
398
+ ```
399
+
400
+ App home view example:
401
+
402
+ ```ruby
403
+ module MySlackBot
404
+ class AppHomeView < SlackBot::View
405
+ def index_view
406
+ blocks = []
407
+ if current_user.present?
408
+ blocks += {
409
+ type: "section",
410
+ text: {
411
+ type: "mrkdwn",
412
+ text:
413
+ "*Hello, #{current_user.name}!*"
414
+ }
415
+ }
416
+ else
417
+ blocks << {
418
+ type: "section",
419
+ text: {
420
+ type: "mrkdwn",
421
+ text:
422
+ "*Please login at https://example.com using Slack*"
423
+ }
424
+ }
425
+ end
426
+ blocks << {
427
+ type: "context",
428
+ elements: [
429
+ {
430
+ type: "mrkdwn",
431
+ text: "Last updated at #{Time.current.strftime("%H:%M:%S %d.%m.%Y")}"
432
+ }
433
+ ]
434
+ }
435
+ { type: "home", blocks: blocks }
436
+ end
437
+
438
+ private
439
+
440
+ def format_date(date)
441
+ date.strftime("%d.%m.%Y")
442
+ end
443
+ end
444
+ end
445
+ ```
446
+
447
+ ## Event example
448
+
449
+ ```ruby
450
+ module MySlackBot
451
+ class AppHomeOpenedEvent < SlackBot::Event
452
+ view MySlackBot::AppHomeView
453
+ def call
454
+ publish_view :index_view
455
+ end
456
+ end
457
+ end
458
+ ```
459
+
460
+ ## Extensibility
461
+
462
+ You can patch any class or module in this gem to extend its functionality, most of parts are not hardly attached to each other.
463
+
464
+ ## Development and testing
465
+
466
+ For development and testing purposes you can use [Cloudflare Argo Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps) to expose your local development environment to the internet.
467
+
468
+ ```sh
469
+ brew install cloudflare/cloudflare/cloudflared
470
+ cloudflared login
471
+ sudo cloudflared tunnel run --token <LONG_TOKEN_FROM_TUNNEL_PAGE>
472
+ ```
473
+
474
+ For easiness of getting information, most of endpoints have `SlackBot::DevConsole.log` calls that will print out information to the console.
475
+
476
+ ## Contributing
477
+
478
+ Bug reports and pull requests are welcome on GitHub at https://github.com/amkisko/grape-slack-bot.rb
479
+
480
+ Contribution policy:
481
+ - It might take up to 2 calendar weeks to review and merge critical fixes
482
+ - It might take up to 6 calendar months to review and merge pull request
483
+ - It might take up to 1 calendar year to review an issue
484
+ - New Slack features are not nessessarily added to the gem
485
+ - Pull request should have test coverage for affected parts
486
+ - Pull request should have changelog entry
487
+
488
+ ## Publishing
489
+
490
+ ```sh
491
+ rm grape-slack-bot-*.gem
492
+ gem build grape-slack-bot.gemspec
493
+ gem push grape-slack-bot-*.gem
494
+ ```
495
+
496
+ ## License
497
+
498
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,45 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.name = "grape-slack-bot"
5
+ gem.version = File.read(File.expand_path('../lib/slack_bot.rb', __FILE__)).match(/VERSION\s*=\s*'(.*?)'/)[1]
6
+
7
+ repository_url = "https://github.com/amkisko/grape-slack-bot.rb"
8
+ root_files = %w(CHANGELOG.md LICENSE.md README.md)
9
+ root_files << "#{gem.name}.gemspec"
10
+
11
+ gem.license = "MIT"
12
+
13
+ gem.platform = Gem::Platform::RUBY
14
+
15
+ gem.authors = ["Andrei Makarov"]
16
+ gem.email = ["andrei@kiskolabs.com"]
17
+ gem.homepage = repository_url
18
+ gem.summary = %q{Slack bot implementation for ruby-grape}
19
+ gem.description = gem.summary
20
+ gem.metadata = {
21
+ "homepage" => repository_url,
22
+ "source_code_uri" => repository_url,
23
+ "bug_tracker_uri" => "#{repository_url}/issues",
24
+ "changelog_uri" => "#{repository_url}/blob/main/CHANGELOG.md",
25
+ "rubygems_mfa_required" => "true"
26
+ }
27
+
28
+ gem.executables = Dir.glob("bin/*").map{ |f| File.basename(f) }
29
+ gem.files = Dir.glob("lib/**/*.rb") + Dir.glob("bin/**/*") + root_files
30
+ gem.test_files = Dir.glob("spec/**/*_spec.rb")
31
+
32
+ gem.required_ruby_version = ">= 1.9.3"
33
+ gem.require_paths = ["lib"]
34
+
35
+ gem.add_runtime_dependency 'rack', '~> 2'
36
+ gem.add_runtime_dependency 'grape', '~> 1'
37
+ gem.add_runtime_dependency 'faraday', '~> 1'
38
+ gem.add_runtime_dependency 'activesupport', '~> 7'
39
+
40
+ gem.add_development_dependency 'bundler', '~> 2'
41
+ gem.add_development_dependency 'rake', '~> 13'
42
+ gem.add_development_dependency 'pry-byebug', '~> 3'
43
+ gem.add_development_dependency 'rspec', '~> 3'
44
+ gem.add_development_dependency 'activesupport', '~> 7'
45
+ end
@@ -0,0 +1,63 @@
1
+ require 'faraday'
2
+
3
+ module SlackBot
4
+ class ApiResponse
5
+ attr_reader :response
6
+ def initialize(&block)
7
+ @response = block.call
8
+ SlackBot::DevConsole.log_output "#{self.class.name}: #{response.body}"
9
+ end
10
+
11
+ def ok?
12
+ response.status == 200 && data["ok"]
13
+ end
14
+
15
+ def error
16
+ data["error"]
17
+ end
18
+
19
+ def data
20
+ JSON.parse(response.body)
21
+ end
22
+ end
23
+ class ApiClient
24
+ attr_reader :client
25
+ def initialize(authorization_token: ENV["SLACK_BOT_API_TOKEN"])
26
+ authorization_token_available = !authorization_token.nil? && authorization_token.is_a?(String) && !authorization_token.empty?
27
+ raise "Slack bot API token is not set" if !authorization_token_available
28
+
29
+ @client =
30
+ Faraday.new do |conn|
31
+ conn.request :url_encoded
32
+ conn.adapter Faraday.default_adapter
33
+ conn.url_prefix = "https://slack.com/api/"
34
+ conn.headers["Content-Type"] = "application/json; charset=utf-8"
35
+ conn.headers["Authorization"] = "Bearer #{authorization_token}"
36
+ end
37
+ end
38
+
39
+ def views_open(trigger_id:, view:)
40
+ ApiResponse.new { client.post("views.open", { trigger_id: trigger_id, view: view }.to_json) }
41
+ end
42
+
43
+ def views_update(view_id:, view:)
44
+ ApiResponse.new { client.post("views.update", { view_id: view_id, view: view }.to_json) }
45
+ end
46
+
47
+ def chat_post_message(channel:, text:, blocks:)
48
+ ApiResponse.new { client.post("chat.postMessage", { channel: channel, text: text, blocks: blocks }.to_json) }
49
+ end
50
+
51
+ def chat_update(channel:, ts:, text:, blocks:)
52
+ ApiResponse.new { client.post("chat.update", { channel: channel, ts: ts, text: text, blocks: blocks }.to_json) }
53
+ end
54
+
55
+ def users_info(user_id:)
56
+ ApiResponse.new { client.post("users.info", { user: user_id }.to_json) }
57
+ end
58
+
59
+ def views_publish(user_id:, view:)
60
+ ApiResponse.new { client.post("views.publish", { user_id: user_id, view: view }.to_json) }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,58 @@
1
+ require 'rack/utils'
2
+ require 'active_support/core_ext/hash/indifferent_access'
3
+
4
+ module SlackBot
5
+ class ArgsParser
6
+ def initialize(args)
7
+ @args = args
8
+ end
9
+ def call
10
+ Rack::Utils.parse_query(@args)
11
+ end
12
+ end
13
+ class ArgsBuilder
14
+ def initialize(args)
15
+ @args = args
16
+ end
17
+ def call
18
+ Rack::Utils.build_query(@args)
19
+ end
20
+ end
21
+ class Args
22
+ attr_accessor :args
23
+ def initialize(builder: ArgsBuilder, parser: ArgsParser)
24
+ @args = {}
25
+ @builder = builder
26
+ @parser = parser
27
+ end
28
+
29
+ def [](key)
30
+ args[key]
31
+ end
32
+
33
+ def []=(key, value)
34
+ args[key] = value
35
+ end
36
+
37
+ def raw_args=(raw_args)
38
+ @raw_args = raw_args
39
+ self.args = @parser.new(raw_args).call&.with_indifferent_access || {}
40
+ end
41
+
42
+ def to_s
43
+ @builder.new(args).call
44
+ end
45
+
46
+ def merge(**other_args)
47
+ self.class.new.tap do |new_args|
48
+ new_args.args = args.merge(other_args)
49
+ end
50
+ end
51
+
52
+ def except(*keys)
53
+ self.class.new.tap do |new_args|
54
+ new_args.args = args.except(*keys)
55
+ end
56
+ end
57
+ end
58
+ end