twibot 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/History.txt ADDED
@@ -0,0 +1,4 @@
1
+ == 1.0.0 / 2009-03-04
2
+
3
+ * 1 major enhancement
4
+ * Birthday!
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ # Look in the tasks/setup.rb file for the various options that can be
2
+ # configured in this Rakefile. The .rake files in the tasks directory
3
+ # are where the options are used.
4
+
5
+ begin
6
+ require 'bones'
7
+ Bones.setup
8
+ rescue LoadError
9
+ begin
10
+ load 'tasks/setup.rb'
11
+ rescue LoadError
12
+ raise RuntimeError, '### please install the "bones" gem ###'
13
+ end
14
+ end
15
+
16
+ ensure_in_path 'lib'
17
+ require 'twibot'
18
+
19
+ task :default => 'test:run'
20
+
21
+ PROJ.name = 'twibot'
22
+ PROJ.authors = 'Christian Johansen'
23
+ PROJ.email = 'christian@cjohansen.no'
24
+ PROJ.url = 'http://github.com/cjohansen/twibot/'
25
+ PROJ.version = Twibot::VERSION
26
+ PROJ.rubyforge.name = 'twibot'
27
+ PROJ.readme_file = 'Readme.rdoc'
28
+ PROJ.rdoc.remote_dir = 'twibot'
29
+
30
+ depend_on "twitter4r", "0.3.1"
data/Readme.rdoc ADDED
@@ -0,0 +1,158 @@
1
+ =Twibot
2
+ Official URL: http://github.com/cjohansen/twibot/tree/master
3
+ Christian Johansen (http://www.cjohansen.no)
4
+
5
+ == DESCRIPTION
6
+
7
+ Twibot (pronounced like "Abbot"), is a Ruby microframework for creating Twitter
8
+ bots, heavily inspired by Sinatra.
9
+
10
+ == USAGE
11
+
12
+ === Simple example
13
+
14
+ require 'twibot'
15
+
16
+ # Receive messages, and tweet them publicly
17
+ #
18
+ message do |message, params|
19
+ post_tweet message
20
+ end
21
+
22
+ # Respond to @replies if they come from the right crowd
23
+ #
24
+ reply :from => [:cjno, :irbno] do |message, params|
25
+ post_tweet "@#{message.sender.screen_name} I agree"
26
+ end
27
+
28
+ # Listen in and log tweets
29
+ #
30
+ tweet do |message, params|
31
+ MyApp.log_tweet(message)
32
+ end
33
+
34
+ === Running the bot
35
+
36
+ To run the bot, simply do:
37
+
38
+ ruby bot.rb
39
+
40
+ === Configuration
41
+
42
+ Twibot looks for a configuration file in ./config/bot.yml. It should contain
43
+ atleast:
44
+
45
+ login: twitter_login
46
+ password: twitter_password
47
+
48
+ You can also pass configuration as command line arguments:
49
+
50
+ ruby bot.rb --login myaccount
51
+
52
+ ...or configure with Ruby:
53
+
54
+ configure do |conf|
55
+ conf.login = "my_account"
56
+ do
57
+
58
+ If you don't specify login and/or password in any of these ways, Twibot will
59
+ prompt you for those.
60
+
61
+ If you want to change how Twibot is configured, you can setup the bot instance
62
+ manually and give it only the configuration options you want:
63
+
64
+ # Create bot only with default configuration
65
+ require 'twibot'
66
+ bot = Twibot::Bot.new(Twibot::Config.default)
67
+
68
+ # Application here...
69
+
70
+ If you want command line arguments you can do:
71
+
72
+ require 'twibot'
73
+ bot = Twibot::Bot.new(Twibot::Config.default << Twibot::CliConfig.new)
74
+
75
+ === "Routes"
76
+
77
+ Like Sinatra, and other web app frameworks, Twibot supports "routes": patterns
78
+ to match incoming tweets and messages:
79
+
80
+ require 'twibot'
81
+
82
+ tweet "time :country :city" do |message,params|
83
+ time = MyTimeService.lookup(params[:country], params[:city])
84
+ client.message :post, "Time is #{time} in #{params[:city]}, #{params[:country]}"
85
+ end
86
+
87
+ You can have several "tweet" blocks (or "message" or "reply"). The first one to
88
+ match an incoming tweet/message will handle it.
89
+
90
+ === Working with the Twitter API
91
+
92
+ The DSL gives you access to your Twitter client instance through "client" (or "twitter"):
93
+
94
+ message do
95
+ twitter.status :post, "Hello world" # Also: client.status :post, "Hello world"
96
+ end
97
+
98
+ == REQUIREMENTS
99
+
100
+ Twitter4r. You'll need atleast 0.3.1, which is currently only available from GitHub.
101
+ Versions of Twitter4r prior to 0.3.1 does not allow for the since_id parameter to be
102
+ appended to URLs to the REST API. Twibot needs these to only fetch fresh messages
103
+ and tweets.
104
+
105
+ == INSTALLATION
106
+
107
+ gem install twibot
108
+
109
+ == Is it Ruby 1.9?
110
+
111
+ Unfortunately no. Context, used for Twibots tests is not Ruby 1.9 compliant, which
112
+ makes it hard to figure out which part is causing trouble. Will be fixed soon. Fork
113
+ away if you want to help out!
114
+
115
+ == Polling
116
+
117
+ Twitter pulled the plug on it's xmpp service last year. This means that Twibot backed
118
+ bots needs to poll the Twitter service to keep up. Twitter has a request limit on 70
119
+ reqs/hour, so you should configure your bot not to make more than that, else it will
120
+ fail. You can ask for your bot account to be put on the whitelist which allows you to
121
+ make 20.000 reqs/hour, and shouldn't be a problem so long as your intentions are good
122
+ (I think).
123
+
124
+ Twibot polls like this:
125
+ * Poll messages if any message handlers exist
126
+ * Poll tweets if any tweet or reply handlers exist
127
+ * Sleep for +interval+ seconds
128
+ * Go over again
129
+
130
+ As long as Twibot finds any messages and/or tweets, the interval stays the same
131
+ (min_interval configuration switch). If nothing was found however, the interval to
132
+ sleep is increased by interval_step configuration option. This happens until it
133
+ reaches max_interval, where it will stay until Twibot finds anything.
134
+
135
+ == LICENSE
136
+
137
+ (The MIT License)
138
+
139
+ Copyright (c) 2009 Christian Johansen
140
+
141
+ Permission is hereby granted, free of charge, to any person obtaining
142
+ a copy of this software and associated documentation files (the
143
+ 'Software'), to deal in the Software without restriction, including
144
+ without limitation the rights to use, copy, modify, merge, publish,
145
+ distribute, sublicense, and/or sell copies of the Software, and to
146
+ permit persons to whom the Software is furnished to do so, subject to
147
+ the following conditions:
148
+
149
+ The above copyright notice and this permission notice shall be
150
+ included in all copies or substantial portions of the Software.
151
+
152
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
153
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
154
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
155
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
156
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
157
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
158
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/lib/hash.rb ADDED
@@ -0,0 +1,8 @@
1
+ class Hash
2
+ def symbolize_keys!
3
+ replace(inject({}) do |hash,(key,value)|
4
+ hash[key.to_sym] = value.is_a?(Hash) ? value.symbolize_keys! : value
5
+ hash
6
+ end)
7
+ end
8
+ end
data/lib/twibot/bot.rb ADDED
@@ -0,0 +1,201 @@
1
+ require 'logger'
2
+ require File.join(File.expand_path(File.dirname(__FILE__)), 'macros')
3
+ require File.join(File.expand_path(File.dirname(__FILE__)), 'handlers')
4
+
5
+ module Twibot
6
+ #
7
+ # Main bot "controller" class
8
+ #
9
+ class Bot
10
+ include Twibot::Handlers
11
+ attr_reader :twitter
12
+ attr_writer :prompt
13
+
14
+ def initialize(options = nil, prompt = false)
15
+ @prompt = prompt
16
+ @conf = nil
17
+ @config = options || Twibot::Config.default << Twibot::FileConfig.new << Twibot::CliConfig.new
18
+ @twitter = Twitter::Client.new :login => config[:login], :password => config[:password]
19
+ @log = nil
20
+ @abort = false
21
+
22
+ @processed = {
23
+ :message => nil,
24
+ :reply => nil,
25
+ :tweet => nil
26
+ }
27
+ rescue Exception => krash
28
+ raise SystemExit.new krash.message
29
+ end
30
+
31
+ def prompt?
32
+ @prompt
33
+ end
34
+
35
+ #
36
+ # Run application
37
+ #
38
+ def run!
39
+ puts "Twibot #{Twibot::VERSION} imposing as @#{login}"
40
+
41
+ trap(:INT) do
42
+ puts "\nAnd it's a wrap. See ya soon!"
43
+ exit
44
+ end
45
+
46
+ # Make sure we don't process messages and tweets received prior to bot launch
47
+ messages = @twitter.messages(:received, { :count => 1 })
48
+ @processed[:message] = messages.first.id if messages.length > 0
49
+
50
+ handle_tweets = @handlers[:tweet].length + @handlers[:reply].length > 0
51
+ tweets = handle_tweets ? @twitter.timeline_for(:me, { :count => 1 }) : []
52
+ @processed[:tweet] = tweets.first.id if tweets.length > 0
53
+ @processed[:reply] = tweets.first.id if tweets.length > 0
54
+
55
+ poll
56
+ end
57
+
58
+ #
59
+ # Poll Twitter API in a loop and pass on messages and tweets when they appear
60
+ #
61
+ def poll
62
+ max = max_interval
63
+ step = interval_step
64
+ interval = min_interval
65
+
66
+ while !@abort do
67
+ message_count = 0
68
+ message_count += receive_messages || 0
69
+ message_count += receive_replies || 0
70
+ message_count += receive_tweets || 0
71
+
72
+ interval = message_count > 0 ? min_interval : [interval + step, max].min
73
+
74
+ log.debug "Sleeping for #{interval}s"
75
+ sleep interval
76
+ end
77
+ end
78
+
79
+ #
80
+ # Receive direct messages
81
+ #
82
+ def receive_messages
83
+ type = :message
84
+ return false unless handlers[type].length > 0
85
+
86
+ options = { :since_id => @processed[type] } if @processed[type]
87
+ dispatch_messages(type, @twitter.messages(:received, options), %w{message messages})
88
+ end
89
+
90
+ #
91
+ # Receive tweets
92
+ #
93
+ def receive_tweets
94
+ type = :tweet
95
+ return false unless handlers[type].length > 0
96
+
97
+ options = { :id => @processed[type] } if @processed[type]
98
+ dispatch_messages(type, @twitter.timeline_for(:me, options), %w{tweet tweets})
99
+ end
100
+
101
+ #
102
+ # Receive tweets that start with @<login>
103
+ #
104
+ def receive_replies
105
+ type = :reply
106
+ return false unless handlers[type].length > 0
107
+
108
+ options = { :id => @processed[type] } if @processed[type]
109
+ messages = @twitter.timeline_for(:me, options)
110
+
111
+ # Pick only messages that start with our name
112
+ num = dispatch_messages(type, messages.find_all { |t| t.text =~ /^@#{@twitter.send :login}/ }, %w{reply replies})
113
+
114
+ # Avoid picking up messages over again
115
+ @processed[type] = messages.first.id if messages.length > 0
116
+ num
117
+ end
118
+
119
+ #
120
+ # Dispatch a collection of messages
121
+ #
122
+ def dispatch_messages(type, messages, labels)
123
+ messages.each { |message| dispatch(type, message) }
124
+ @processed[type] = messages.first.id if messages.length > 0
125
+
126
+ num = messages.length
127
+ log.info "Received #{num} #{num == 1 ? labels[0] : labels[1]}"
128
+ num
129
+ end
130
+
131
+ #
132
+ # Return logger instance
133
+ #
134
+ def log
135
+ return @log if @log
136
+ os = config[:log_file] ? File.open(config[:log_file], "a") : $stdout
137
+ @log = Logger.new(os)
138
+ @log.level = Logger.const_get(config[:log_level] ? config[:log_level].upcase : "INFO")
139
+ @log
140
+ end
141
+
142
+ #
143
+ # Configure bot
144
+ #
145
+ def configure
146
+ yield @config
147
+ @conf = nil
148
+ end
149
+
150
+ private
151
+ #
152
+ # Map configuration settings
153
+ #
154
+ def method_missing(name, *args, &block)
155
+ return super unless config.key?(name)
156
+
157
+ self.class.send(:define_method, name) { config[name] }
158
+ config[name]
159
+ end
160
+
161
+ #
162
+ # Return configuration
163
+ #
164
+ def config
165
+ return @conf if @conf
166
+ @conf = @config.to_hash
167
+
168
+ if prompt? && (!@conf[:login] || !@conf[:password])
169
+ # No need to rescue LoadError - if the gem is missing then config will
170
+ # be incomplete, something which will be detected elsewhere
171
+ begin
172
+ require 'highline'
173
+ hl = HighLine.new
174
+
175
+ @config.login = hl.ask("Twitter login: ") unless @conf[:login]
176
+ @config.password = hl.ask("Twitter password: ") { |q| q.echo = '*' } unless @conf[:password]
177
+ @conf = @config.to_hash
178
+ rescue LoadError
179
+ raise SystemExit.new <<-HELP
180
+ Unable to continue without login and password. Do one of the following:
181
+ 1) Install the HighLine gem (gem install highline) to be prompted for credentials
182
+ 2) Create a config/bot.yml with login: and password:
183
+ 3) Put a configure { |conf| conf.login = "..." } block in your bot application
184
+ 4) Run bot with --login and --password options
185
+ HELP
186
+ end
187
+ end
188
+
189
+ @conf
190
+ end
191
+ end
192
+ end
193
+
194
+ # Expose DSL
195
+ include Twibot::Macros
196
+
197
+ # Run bot if macros has been used
198
+ at_exit do
199
+ raise $! if $!
200
+ @@bot.run! if run?
201
+ end
@@ -0,0 +1,136 @@
1
+ require 'optparse'
2
+
3
+ module Twibot
4
+ #
5
+ # Twibot configuration. Use either Twibot::CliConfig.new or
6
+ # TwibotFileConfig.new setup a new bot from either command line or file
7
+ # (respectively). Configurations can be chained so they override each other:
8
+ #
9
+ # config = Twibot::FileConfig.new
10
+ # config << Twibot::CliConfig.new
11
+ # config.to_hash
12
+ #
13
+ # The preceding example will create a configuration which is based on a
14
+ # configuration file but have certain values overridden from the command line.
15
+ # This can be used for instance to store everything but the Twitter account
16
+ # password in your configuration file. Then you can just provide the password
17
+ # when running the bot.
18
+ #
19
+ class Config
20
+ attr_reader :settings
21
+
22
+ DEFAULT = {
23
+ :min_interval => 30,
24
+ :max_interval => 300,
25
+ :interval_step => 10,
26
+ :log_level => "info",
27
+ :log_file => nil,
28
+ :login => nil,
29
+ :password => nil,
30
+ :prompt => false,
31
+ :daemonize => false
32
+ }
33
+
34
+ def initialize(settings = {})
35
+ @configs = []
36
+ @settings = settings
37
+ end
38
+
39
+ #
40
+ # Add a configuration object to override given settings
41
+ #
42
+ def add(config)
43
+ @configs << config
44
+ self
45
+ end
46
+
47
+ alias_method :<<, :add
48
+
49
+ #
50
+ # Makes it possible to access configuration settings as attributes
51
+ #
52
+ def method_missing(name, *args, &block)
53
+ regex = /=$/
54
+ attr_name = name.to_s.sub(regex, '').to_sym
55
+ return super if name == attr_name && !@settings.key?(attr_name)
56
+
57
+ if name != attr_name
58
+ @settings[attr_name] = args.first
59
+ end
60
+
61
+ @settings[attr_name]
62
+ end
63
+
64
+ #
65
+ # Merges configurations and returns a hash with all options
66
+ #
67
+ def to_hash
68
+ hash = {}.merge(@settings)
69
+ @configs.each { |conf| hash.merge!(conf.to_hash) }
70
+ hash
71
+ end
72
+
73
+ def self.default
74
+ Config.new({}.merge(DEFAULT))
75
+ end
76
+ end
77
+
78
+ #
79
+ # Configuration from command line
80
+ #
81
+ class CliConfig < Config
82
+
83
+ def initialize(args = $*)
84
+ super()
85
+
86
+ @parser = OptionParser.new do |opts|
87
+ opts.banner += "Usage: #{File.basename(Twibot.app_file)} [options]"
88
+
89
+ opts.on("-m", "--min-interval SECS", Integer, "Minimum poll interval in seconds") { |i| @settings[:min_interval] = i }
90
+ opts.on("-x", "--max-interval SECS", Integer, "Maximum poll interval in seconds") { |i| @settings[:max_interval] = i }
91
+ opts.on("-s", "--interval-step SECS", Integer, "Poll interval step in seconds") { |i| @settings[:interval_step] = i }
92
+ opts.on("-f", "--log-file FILE", "Log file") { |f| @settings[:log_file] = f }
93
+ opts.on("-l", "--log-level LEVEL", "Log level (err, warn, info, debug), default id info") { |l| @settings[:log_level] = l }
94
+ opts.on("-u", "--login LOGIN", "Twitter login") { |l| @settings[:login] = l }
95
+ opts.on("-p", "--password PASSWORD", "Twitter password") { |p| @settings[:password] = p }
96
+ opts.on("-h", "--help", "Show this message") { puts opts; exit }
97
+
98
+ begin
99
+ require 'daemons'
100
+ opts.on("-d", "--daemonize", "Run as background process (Not implemented)") { |t| @settings[:daemonize] = true }
101
+ rescue LoadError
102
+ end
103
+
104
+ end.parse!(args)
105
+ end
106
+ end
107
+
108
+ #
109
+ # Configuration from files
110
+ #
111
+ class FileConfig < Config
112
+
113
+ #
114
+ # Accepts a stream or a file to read configuration from
115
+ # Default is to read configuration from ./config/bot.yml
116
+ #
117
+ # If a stream is passed it is not closed from within the method
118
+ #
119
+ def initialize(fos = File.expand_path("config/bot.yml"))
120
+ stream = fos.is_a?(String) ? File.open(fos, "r") : fos
121
+
122
+ begin
123
+ config = YAML.load(stream.read)
124
+ config.symbolize_keys! if config
125
+ rescue Exception => err
126
+ puts err.message
127
+ puts "Unable to load configuration, aborting"
128
+ exit
129
+ ensure
130
+ stream.close if fos.is_a?(String)
131
+ end
132
+
133
+ super config.is_a?(Hash) ? config : {}
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,103 @@
1
+ module Twibot
2
+ module Handlers
3
+ #
4
+ # Add a handler for this bot
5
+ #
6
+ def add_handler(type, handler)
7
+ handlers[type] << handler
8
+ handler
9
+ end
10
+
11
+ def dispatch(type, message)
12
+ handlers[type].each { |handler| handler.dispatch(message) }
13
+ end
14
+
15
+ def handlers
16
+ @handlers ||= {
17
+ :message => [],
18
+ :reply => [],
19
+ :tweet => []
20
+ }
21
+ end
22
+
23
+ def handlers=(hash)
24
+ @handlers = hash
25
+ end
26
+ end
27
+
28
+ #
29
+ # A Handler object is an object which can handle a direct message, tweet or
30
+ # at reply.
31
+ #
32
+ class Handler
33
+ def initialize(pattern = nil, options = {}, &blk)
34
+ if pattern.is_a?(Hash)
35
+ options = pattern
36
+ pattern = nil
37
+ end
38
+
39
+ @options = options
40
+ @options[:from].collect! { |s| s.to_s } if @options[:from] && @options[:from].is_a?(Array)
41
+ @options[:from] = [@options[:from].to_s] if @options[:from] && @options[:from].is_a?(String)
42
+ @handler = nil
43
+ @handler = block_given? ? blk : nil
44
+ self.pattern = pattern
45
+ end
46
+
47
+ #
48
+ # Parse pattern string and set options
49
+ #
50
+ def pattern=(pattern)
51
+ return if pattern.nil? || pattern == ""
52
+
53
+ words = pattern.split.collect { |s| s.strip } # Get all words in pattern
54
+ @options[:tokens] = words.inject([]) do |sum, token| # Find all tokens, ie :symbol :like :names
55
+ next sum unless token =~ /^:.*/ # Don't process regular words
56
+ sym = token.sub(":", "").to_sym # Turn token string into symbol, ie ":token" => :token
57
+ regex = @options[sym] || '[^\s]+' # Fetch regex if configured, else use any character but space matching
58
+ pattern.sub!(/(^|\s)#{token}(\s|$)/, '\1(' + regex.to_s + ')\2') # Make sure regex captures named switch
59
+ sum << sym
60
+ end
61
+
62
+ @options[:pattern] = /#{pattern}(\s.+)?/
63
+ end
64
+
65
+ #
66
+ # Determines if this handler is suited to handle an incoming message
67
+ #
68
+ def recognize?(message)
69
+ return false if @options[:pattern] && message.text !~ @options[:pattern] # Pattern check
70
+
71
+ users = @options[:from] ? @options[:from] : nil
72
+ return false if users && !users.include?(message.sender.screen_name) # Check allowed senders
73
+ true
74
+ end
75
+
76
+ #
77
+ # Process message to build params hash and pass message along with params of
78
+ # to +handle+
79
+ #
80
+ def dispatch(message)
81
+ return unless recognize?(message)
82
+ @params = {}
83
+
84
+ if @options[:pattern]
85
+ matches = message.text.match(@options[:pattern])
86
+ @options[:tokens].each_with_index { |token, i| @params[token] = matches[i+1] }
87
+ @params[:text] = (matches[@options[:tokens].length+1] || "").strip
88
+ else
89
+ @params[:text] = message.text
90
+ end
91
+
92
+ handle(message, @params)
93
+ end
94
+
95
+ #
96
+ # Handle a message. Calls the internal Proc with the message and the params
97
+ # hash as parameters.
98
+ #
99
+ def handle(message, params)
100
+ @handler.call(message, params) if @handler
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,66 @@
1
+ module Twibot
2
+ @@prompt = false
3
+
4
+ def self.prompt=(p)
5
+ @@prompt = f
6
+ end
7
+
8
+ module Macros
9
+ def self.included(mod)
10
+ @@bot = nil
11
+ end
12
+
13
+ def configure(&blk)
14
+ bot.configure(&blk)
15
+ end
16
+
17
+ def message(pattern = nil, options = {}, &blk)
18
+ add_handler(:message, pattern, options, &blk)
19
+ end
20
+
21
+ def reply(pattern = nil, options = {}, &blk)
22
+ add_handler(:reply, pattern, options, &blk)
23
+ end
24
+
25
+ def tweet(pattern = nil, options = {}, &blk)
26
+ add_handler(:tweet, pattern, options, &blk)
27
+ end
28
+
29
+ def twitter
30
+ bot.twitter
31
+ end
32
+
33
+ alias_method :client, :twitter
34
+
35
+ def post_tweet(msg)
36
+ message = msg.respond_to?(:text) ? msg.text : msg
37
+ puts message
38
+ client.status(:post, message)
39
+ end
40
+
41
+ def run?
42
+ !@@bot.nil?
43
+ end
44
+
45
+ private
46
+ def add_handler(type, pattern, options, &blk)
47
+ bot.add_handler(type, Twibot::Handler.new(pattern, options, &blk))
48
+ end
49
+
50
+ def bot
51
+ return @@bot unless @@bot.nil?
52
+
53
+ begin
54
+ @@bot = Twibot::Bot.new nil, true
55
+ rescue Exception
56
+ @@bot = Twibot::Bot.new(Twibot::Config.default << Twibot::CliConfig.new, true)
57
+ end
58
+
59
+ @@bot
60
+ end
61
+
62
+ def self.bot=(bot)
63
+ @@bot = bot
64
+ end
65
+ end
66
+ end