kanal 0.3.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 +7 -0
- data/.DS_Store +0 -0
- data/.rspec +3 -0
- data/.rubocop.yml +14 -0
- data/.ruby-version +1 -0
- data/.vscode/settings.json +8 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +108 -0
- data/LICENSE.txt +21 -0
- data/README.md +45 -0
- data/Rakefile +12 -0
- data/kanal.gemspec +39 -0
- data/lib/kanal/core/conditions/condition.rb +36 -0
- data/lib/kanal/core/conditions/condition_creator.rb +32 -0
- data/lib/kanal/core/conditions/condition_pack.rb +52 -0
- data/lib/kanal/core/conditions/condition_pack_creator.rb +41 -0
- data/lib/kanal/core/conditions/condition_storage.rb +58 -0
- data/lib/kanal/core/core.rb +214 -0
- data/lib/kanal/core/helpers/condition_finder.rb +26 -0
- data/lib/kanal/core/helpers/parameter_bag.rb +22 -0
- data/lib/kanal/core/helpers/parameter_bag_with_registrator.rb +46 -0
- data/lib/kanal/core/helpers/parameter_finder_with_method_missing_mixin.rb +38 -0
- data/lib/kanal/core/helpers/parameter_registrator.rb +48 -0
- data/lib/kanal/core/helpers/router_proc_parser.rb +47 -0
- data/lib/kanal/core/hooks/hook_storage.rb +109 -0
- data/lib/kanal/core/input/input.rb +20 -0
- data/lib/kanal/core/interfaces/interface.rb +65 -0
- data/lib/kanal/core/logger/logger.rb +12 -0
- data/lib/kanal/core/output/output.rb +31 -0
- data/lib/kanal/core/output/output_creator.rb +17 -0
- data/lib/kanal/core/plugins/plugin.rb +44 -0
- data/lib/kanal/core/router/router.rb +97 -0
- data/lib/kanal/core/router/router_node.rb +131 -0
- data/lib/kanal/core/router/router_storage.rb +33 -0
- data/lib/kanal/core/services/service_container.rb +97 -0
- data/lib/kanal/interfaces/simple_cli/simple_cli_interface.rb +51 -0
- data/lib/kanal/plugins/batteries/batteries_plugin.rb +116 -0
- data/lib/kanal/version.rb +5 -0
- data/lib/kanal.rb +9 -0
- data/sig/kanal/core/conditions/condition_pack.rbs +9 -0
- data/sig/kanal/core/conditions/condition_storage.rbs +9 -0
- data/sig/kanal.rbs +4 -0
- metadata +90 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
module Kanal
|
2
|
+
module Core
|
3
|
+
module Output
|
4
|
+
# This class helps creating output with the help
|
5
|
+
# of handy dsl format
|
6
|
+
class OutputCreator
|
7
|
+
def initialize(input)
|
8
|
+
@input = input
|
9
|
+
end
|
10
|
+
|
11
|
+
def create(&block)
|
12
|
+
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rake"
|
4
|
+
|
5
|
+
module Kanal
|
6
|
+
module Core
|
7
|
+
module Plugins
|
8
|
+
# Base class for plugins that can be registered in the core
|
9
|
+
class Plugin
|
10
|
+
#
|
11
|
+
# Name of the plugin, for it to be available
|
12
|
+
# for finding and getting
|
13
|
+
#
|
14
|
+
# @return [Symbol] <description>
|
15
|
+
#
|
16
|
+
def name
|
17
|
+
raise NotImplementedError
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# This method is for the setting up, it will be executed when plugin
|
22
|
+
# is being added to the core
|
23
|
+
#
|
24
|
+
# @param [Kanal::Core::Core] core <description>
|
25
|
+
#
|
26
|
+
# @return [void] <description>
|
27
|
+
#
|
28
|
+
def setup(core)
|
29
|
+
raise NotImplementedError
|
30
|
+
end
|
31
|
+
|
32
|
+
#
|
33
|
+
# If plugins does have rake tasks available for execution,
|
34
|
+
# require them here. They will be used
|
35
|
+
#
|
36
|
+
# @return [Array<Rake::TaskLib>] <description>
|
37
|
+
#
|
38
|
+
def rake_tasks
|
39
|
+
[]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./router_node"
|
4
|
+
|
5
|
+
module Kanal
|
6
|
+
module Core
|
7
|
+
module Router
|
8
|
+
# Router serves as a container class for
|
9
|
+
# root node of router nodes, also as somewhat
|
10
|
+
# namespace. Basically class router stores all the
|
11
|
+
# router nodes and have a name.
|
12
|
+
class Router
|
13
|
+
attr_reader :name,
|
14
|
+
:core
|
15
|
+
|
16
|
+
def initialize(name, core)
|
17
|
+
@name = name
|
18
|
+
@core = core
|
19
|
+
@root_node = nil
|
20
|
+
@default_node = nil
|
21
|
+
end
|
22
|
+
|
23
|
+
def configure(&block)
|
24
|
+
# Root node does not have parent
|
25
|
+
@root_node ||= RouterNode.new router: self, parent: nil, root: true
|
26
|
+
|
27
|
+
@root_node.instance_eval(&block)
|
28
|
+
end
|
29
|
+
|
30
|
+
def default_response(&block)
|
31
|
+
raise "default node for router #{@name} already defined" if @default_node
|
32
|
+
|
33
|
+
@default_node = RouterNode.new parent: nil, router: self, default: true
|
34
|
+
|
35
|
+
@default_node.respond(&block)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Main method for creating output if it is found or going to default output
|
39
|
+
def create_output_for_input(input)
|
40
|
+
# Checking if default node with output exists throw error if not
|
41
|
+
raise "Please provide default response for router before you try and throw input against it ;)" unless @default_node
|
42
|
+
|
43
|
+
raise "You did not actually .configure router, didn't you? There is no even root node! Use .configure method" unless @root_node
|
44
|
+
|
45
|
+
unless @root_node.children?
|
46
|
+
raise "Hey your router actually does not have ANY routes to work with. Did you even try adding them?"
|
47
|
+
end
|
48
|
+
|
49
|
+
@core.hooks.call :input_before_router, input
|
50
|
+
|
51
|
+
node = test_input_against_router_node input, @root_node
|
52
|
+
|
53
|
+
# No result means no route node was found for that input
|
54
|
+
# using default response
|
55
|
+
node ||= @default_node
|
56
|
+
|
57
|
+
output = node.construct_response input
|
58
|
+
|
59
|
+
@core.hooks.call :output_before_returned, input, output
|
60
|
+
|
61
|
+
output
|
62
|
+
end
|
63
|
+
|
64
|
+
# Recursive method for searching router nodes
|
65
|
+
def test_input_against_router_node(input, router_node)
|
66
|
+
# Allow root node because it does not have any conditions and does not have
|
67
|
+
# any responses, but it does have children. Well, it should have children...
|
68
|
+
# Basically:
|
69
|
+
# if router_node is root - proceed with code
|
70
|
+
# if router_node is not root and condition is not met - stop right here.
|
71
|
+
# Cannot proceed inside of this node.
|
72
|
+
return if !router_node.root? && !router_node.condition_met?(input, @core)
|
73
|
+
|
74
|
+
# Check if node has children first. Router node with children SHOULD NOT HAVE RESPONSE.
|
75
|
+
# There is an exception for this case so don't worry, it's not protected only by
|
76
|
+
# this comment
|
77
|
+
if router_node.children?
|
78
|
+
node = nil
|
79
|
+
|
80
|
+
router_node.children.each do |c|
|
81
|
+
node = test_input_against_router_node input, c
|
82
|
+
|
83
|
+
break if node
|
84
|
+
end
|
85
|
+
|
86
|
+
node
|
87
|
+
elsif router_node.response?
|
88
|
+
# Router node without children can have response
|
89
|
+
router_node
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private :test_input_against_router_node
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../output/output"
|
4
|
+
|
5
|
+
module Kanal
|
6
|
+
module Core
|
7
|
+
# This module stores RouterNode and helper classes which are
|
8
|
+
# important for building router nodes tree
|
9
|
+
module Router
|
10
|
+
# This class is used as a node in router
|
11
|
+
# tree, containing conditions and responses
|
12
|
+
class RouterNode
|
13
|
+
include Output
|
14
|
+
|
15
|
+
attr_reader :parent,
|
16
|
+
:children
|
17
|
+
|
18
|
+
# parameter default: is for knowing that this node
|
19
|
+
# is for default response
|
20
|
+
# default response cannot have child nodes
|
21
|
+
def initialize(*args, router:, parent:, default: false, root: false)
|
22
|
+
@router = router
|
23
|
+
@parent = parent
|
24
|
+
|
25
|
+
@children = []
|
26
|
+
|
27
|
+
@response_block = nil
|
28
|
+
|
29
|
+
@condition_pack_name = nil
|
30
|
+
@condition_name = nil
|
31
|
+
@condition_argument = nil
|
32
|
+
|
33
|
+
# We omit setting conditions because default router node does not need any conditions
|
34
|
+
# Also root node does not have conditions so we basically omit them if arguments are empty
|
35
|
+
return if default || root
|
36
|
+
|
37
|
+
# With this we attach names of condition pack and condition to this router
|
38
|
+
# node, so we will be able to find them later at runtime and use them
|
39
|
+
assign_condition_pack_and_condition_names_from_args!(*args)
|
40
|
+
end
|
41
|
+
|
42
|
+
def on(*args, &block)
|
43
|
+
raise "You cannot add children to nodes with response ready. Response is a final line" if response?
|
44
|
+
|
45
|
+
child = RouterNode.new(*args, router: @router, parent: self)
|
46
|
+
add_child child
|
47
|
+
|
48
|
+
child.instance_eval(&block)
|
49
|
+
end
|
50
|
+
|
51
|
+
def construct_response(input)
|
52
|
+
raise "no response block configured for this node. router: #{@router.name}. debug: #{debug_info}" unless @response_block
|
53
|
+
|
54
|
+
output = Output::Output.new @router.core.output_parameter_registrator, input, @router.core
|
55
|
+
|
56
|
+
output.instance_eval(&@response_block)
|
57
|
+
|
58
|
+
output
|
59
|
+
end
|
60
|
+
|
61
|
+
def respond(&block)
|
62
|
+
raise "Router node with children cannot have response" unless @children.empty?
|
63
|
+
|
64
|
+
@response_block = block
|
65
|
+
end
|
66
|
+
|
67
|
+
def response?
|
68
|
+
!@response_block.nil?
|
69
|
+
end
|
70
|
+
|
71
|
+
# This method processes args to populate condition and condition pack
|
72
|
+
# in this router node
|
73
|
+
def assign_condition_pack_and_condition_names_from_args!(*args)
|
74
|
+
condition_pack_name = args[0]
|
75
|
+
condition_name = args[1]
|
76
|
+
|
77
|
+
# We assume we got condition that requires argument
|
78
|
+
if condition_name.is_a? Hash
|
79
|
+
# We search for arguments inside kwargs
|
80
|
+
@condition_argument = condition_name.values.first
|
81
|
+
|
82
|
+
condition_name = condition_name.keys.first
|
83
|
+
end
|
84
|
+
|
85
|
+
# This calls will raise errors if there is problem with pack or condition
|
86
|
+
# inside of it
|
87
|
+
pack = @router.core.condition_storage.get_condition_pack_by_name! condition_pack_name
|
88
|
+
condition = pack.get_condition_by_name! condition_name
|
89
|
+
|
90
|
+
if condition.with_argument? && !@condition_argument
|
91
|
+
raise "Condition requires argument, though you wrote it as :symbol, not as positional_arg:
|
92
|
+
Please check route with condition pack: #{condition_pack_name} and condition: #{condition_name}"
|
93
|
+
end
|
94
|
+
|
95
|
+
@condition_pack_name = condition_pack_name
|
96
|
+
@condition_name = condition_name
|
97
|
+
end
|
98
|
+
|
99
|
+
def debug_info
|
100
|
+
"RouterNode with condition pack: #{@condition_pack_name}, condition: #{@condition_name}, condition argument: #{@condition_argument}"
|
101
|
+
end
|
102
|
+
|
103
|
+
def condition_met?(input, core)
|
104
|
+
c = condition
|
105
|
+
|
106
|
+
c.met? input, core, @condition_argument
|
107
|
+
end
|
108
|
+
|
109
|
+
def condition
|
110
|
+
pack = @router.core.condition_storage.get_condition_pack_by_name! @condition_pack_name
|
111
|
+
|
112
|
+
pack.get_condition_by_name! @condition_name
|
113
|
+
end
|
114
|
+
|
115
|
+
def root?
|
116
|
+
parent.nil?
|
117
|
+
end
|
118
|
+
|
119
|
+
def children?
|
120
|
+
!@children.empty?
|
121
|
+
end
|
122
|
+
|
123
|
+
def add_child(node)
|
124
|
+
@children.append node
|
125
|
+
end
|
126
|
+
|
127
|
+
private :add_child
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require_relative "./router"
|
2
|
+
|
3
|
+
module Kanal
|
4
|
+
module Core
|
5
|
+
module Router
|
6
|
+
# Class with helper methods for creating and getting routers
|
7
|
+
class RouterStorage
|
8
|
+
def initialize(core)
|
9
|
+
@routers = []
|
10
|
+
@core = core
|
11
|
+
end
|
12
|
+
|
13
|
+
#
|
14
|
+
# Creates router by name and stores it for further access
|
15
|
+
#
|
16
|
+
# @param [Symbol] name <description>
|
17
|
+
#
|
18
|
+
# @return [Kanal::Core::Router::Router] <description>
|
19
|
+
#
|
20
|
+
def get_or_create_router(name)
|
21
|
+
router = @routers.find { |r| r.name == name }
|
22
|
+
|
23
|
+
unless router
|
24
|
+
router = Router.new name, @core
|
25
|
+
@routers.append router
|
26
|
+
end
|
27
|
+
|
28
|
+
router
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module Kanal
|
2
|
+
module Core
|
3
|
+
module Services
|
4
|
+
# Stores info about service registration
|
5
|
+
class ServiceRegistration
|
6
|
+
attr_reader :service_class,
|
7
|
+
:type,
|
8
|
+
:block
|
9
|
+
|
10
|
+
def initialize(service_class, type, block)
|
11
|
+
@service_class = service_class
|
12
|
+
@type = type
|
13
|
+
@block = block
|
14
|
+
end
|
15
|
+
|
16
|
+
def block?
|
17
|
+
!@block.nil?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# Container allows service registration as well as
|
23
|
+
# getting those services. You can register service with different
|
24
|
+
# lifespan types.
|
25
|
+
#
|
26
|
+
class ServiceContainer
|
27
|
+
TYPE_SINGLETON = :singleton
|
28
|
+
TYPE_TRANSIENT = :transient
|
29
|
+
|
30
|
+
def initialize
|
31
|
+
@registrations = {}
|
32
|
+
@services = {}
|
33
|
+
end
|
34
|
+
|
35
|
+
#
|
36
|
+
# Registering service so container knows about it and it's type and it's
|
37
|
+
# optional initialization block
|
38
|
+
#
|
39
|
+
# @param [Symbol] name <description>
|
40
|
+
# @param [class] service_class <description>
|
41
|
+
# @param [Symbol] type <description>
|
42
|
+
# @yield Initialization block. It should be used if your service
|
43
|
+
# requires postponed initialization
|
44
|
+
#
|
45
|
+
# @return [void] <description>
|
46
|
+
#
|
47
|
+
def register_service(name, service_class, type: TYPE_SINGLETON, &block)
|
48
|
+
return if @registrations.key? name
|
49
|
+
|
50
|
+
raise "Unrecognized service type #{type}. Allowed types: #{allowed_types}" unless allowed_types.include? type
|
51
|
+
|
52
|
+
registration = ServiceRegistration.new service_class, type, block
|
53
|
+
|
54
|
+
@registrations[name] = registration
|
55
|
+
end
|
56
|
+
|
57
|
+
#
|
58
|
+
# Gets the registered service by name
|
59
|
+
#
|
60
|
+
# @param [Symbol] name <description>
|
61
|
+
#
|
62
|
+
# @return [Object] <description>
|
63
|
+
#
|
64
|
+
def get(name)
|
65
|
+
raise "Service named #{name} was not registered in container" unless @registrations.key? name
|
66
|
+
|
67
|
+
registration = @registrations[name]
|
68
|
+
|
69
|
+
if registration.type == TYPE_SINGLETON
|
70
|
+
# Created once and reused after creation
|
71
|
+
if @services[name].nil?
|
72
|
+
@services[name] = create_service_from_registration registration
|
73
|
+
end
|
74
|
+
|
75
|
+
@services[name]
|
76
|
+
elsif registration.type == TYPE_TRANSIENT
|
77
|
+
# Created every time
|
78
|
+
create_service_from_registration registration
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def create_service_from_registration(registration)
|
83
|
+
return registration.block.call if registration.block?
|
84
|
+
|
85
|
+
registration.service_class.new
|
86
|
+
end
|
87
|
+
|
88
|
+
def allowed_types
|
89
|
+
[TYPE_SINGLETON, TYPE_TRANSIENT]
|
90
|
+
end
|
91
|
+
|
92
|
+
private :create_service_from_registration,
|
93
|
+
:allowed_types
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../core/interfaces/interface"
|
4
|
+
require_relative "../../plugins/batteries/batteries_plugin"
|
5
|
+
|
6
|
+
module Kanal
|
7
|
+
module Interfaces
|
8
|
+
module SimpleCli
|
9
|
+
# This interface provides input/output with the cli
|
10
|
+
class SimpleCliInterface < Kanal::Core::Interfaces::Interface
|
11
|
+
#
|
12
|
+
# <Description>
|
13
|
+
#
|
14
|
+
# @param [Kanal::Core::Core] core <description>
|
15
|
+
#
|
16
|
+
def initialize(core)
|
17
|
+
super
|
18
|
+
|
19
|
+
# For simple cli we need body
|
20
|
+
@core.register_plugin Kanal::Plugins::Batteries::BatteriesPlugin.new
|
21
|
+
|
22
|
+
@core.register_output_parameter :quit
|
23
|
+
end
|
24
|
+
|
25
|
+
def start
|
26
|
+
loop do
|
27
|
+
puts ">>>"
|
28
|
+
input = @core.create_input
|
29
|
+
input.body = gets
|
30
|
+
|
31
|
+
output = router.create_output_for_input input
|
32
|
+
|
33
|
+
if output.quit
|
34
|
+
puts "Undestood! Quitting"
|
35
|
+
break
|
36
|
+
end
|
37
|
+
|
38
|
+
puts "[bot]: #{output.body}"
|
39
|
+
rescue Interrupt
|
40
|
+
puts "Got it! Hard stop. Bye bye!"
|
41
|
+
break
|
42
|
+
end
|
43
|
+
|
44
|
+
puts "End of conversation!"
|
45
|
+
end
|
46
|
+
|
47
|
+
def stop; end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "kanal/core/plugins/plugin"
|
4
|
+
|
5
|
+
module Kanal
|
6
|
+
module Plugins
|
7
|
+
# This module stores needed classes and helpers for batteries plugin
|
8
|
+
module Batteries
|
9
|
+
# Plugin with some batteries like .body property etc
|
10
|
+
class BatteriesPlugin < Core::Plugins::Plugin
|
11
|
+
def name
|
12
|
+
:batteries
|
13
|
+
end
|
14
|
+
|
15
|
+
def setup(core)
|
16
|
+
source_batteries core
|
17
|
+
body_batteries core
|
18
|
+
flow_batteries core
|
19
|
+
end
|
20
|
+
|
21
|
+
def flow_batteries(core)
|
22
|
+
core.add_condition_pack :flow do
|
23
|
+
add_condition :any do
|
24
|
+
met? do |_, _, _|
|
25
|
+
true
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def source_batteries(core)
|
32
|
+
# This parameter can be filled by different plugins/interfaces
|
33
|
+
# to point from which source message came
|
34
|
+
core.register_input_parameter :source
|
35
|
+
|
36
|
+
core.add_condition_pack :source do
|
37
|
+
add_condition :from do
|
38
|
+
with_argument
|
39
|
+
|
40
|
+
met? do |input, _, argument|
|
41
|
+
input.source == argument
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def body_batteries(core)
|
48
|
+
core.register_input_parameter :body
|
49
|
+
core.register_output_parameter :body
|
50
|
+
|
51
|
+
core.add_condition_pack :body do
|
52
|
+
add_condition :starts_with do
|
53
|
+
with_argument
|
54
|
+
|
55
|
+
met? do |input, _, argument|
|
56
|
+
if input.body.is_a? String
|
57
|
+
input.body.start_with? argument
|
58
|
+
else
|
59
|
+
false
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
add_condition :ends_with do
|
65
|
+
with_argument
|
66
|
+
|
67
|
+
met? do |input, _, argument|
|
68
|
+
if input.body.is_a? String
|
69
|
+
input.body.end_with? argument
|
70
|
+
else
|
71
|
+
false
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
add_condition :contains do
|
77
|
+
with_argument
|
78
|
+
|
79
|
+
met? do |input, _, argument|
|
80
|
+
if input.body.is_a? String
|
81
|
+
input.body.include? argument
|
82
|
+
else
|
83
|
+
false
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
add_condition :contains_one_of do
|
89
|
+
with_argument
|
90
|
+
|
91
|
+
met? do |input, _, argument|
|
92
|
+
met = false
|
93
|
+
|
94
|
+
argument.each do |word|
|
95
|
+
met = input.body.include? word
|
96
|
+
|
97
|
+
break if met
|
98
|
+
end
|
99
|
+
|
100
|
+
met
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
add_condition :equals do
|
105
|
+
with_argument
|
106
|
+
|
107
|
+
met? do |input, _, argument|
|
108
|
+
input.body == argument
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
data/lib/kanal.rb
ADDED
data/sig/kanal.rbs
ADDED