grape-slack-bot 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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