botolo 0.32.9 → 0.50.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cbeb05fdb3cf739092a995a69966641a8f22e7b7
4
- data.tar.gz: 34c717174161db5935f779b1052688f3e88cc811
3
+ metadata.gz: 5db849cbb8017808accaae72cc3f6b430fcf963f
4
+ data.tar.gz: f00801595c7363ad64c12ac6cf1311b0b42a846c
5
5
  SHA512:
6
- metadata.gz: 08396913b0678a35bafaa2ab52c12721523c509289f93da373648a3f8522b2417757ef7b80b2770ad7644c8abab3f5f2f173cd38e39416f6a20833f2dfcd7c71
7
- data.tar.gz: 02941872a2b6f03cbfa002877862e397a4004740030bcb95fbaf278733264776d5009e320603f2e5c45e451711f285d68a031c3f34f40e747b677a67eb21ad21
6
+ metadata.gz: f9a3246e555a4895cce738cc2260f68945f1f8c3579ad5eb88bdd972ff1672673222a57dab80001fdede898b9ed5be8990fa4ea0ec48b3a709b7f8aafdcbacb7
7
+ data.tar.gz: 45a383398b60148e2dcf444c94ed47fcae0acfcab719cb51550b37dd691ac8283fc71437b76e02d9a24383fc5cbe89f308974a794d5c61dd74083bb8319eb5d6
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ botolo
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-2.1.3
data/README.md CHANGED
@@ -39,6 +39,7 @@ bot:
39
39
  email: paolo@codesake.com
40
40
  # This overrides any behaviour file passed as argument
41
41
  behaviour: dummy-bot.rb
42
+ logfile: dummy-bot.log
42
43
 
43
44
  twitter:
44
45
  enabled: no
@@ -110,10 +111,49 @@ botolo:9: warning: already initialized constant OpenSSL::SSL::VERIFY_PEER
110
111
  Custom written behaviour can use the global variable $logger to use botolo logging
111
112
  facilities and having the stdout/stderr prints more consistent.
112
113
 
114
+ The same ruby class can have some social integration adding twitter support in
115
+ its behaviour file.
116
+
117
+ Since botolo version 0.50, more twitter accounts are supported and
118
+ Botolo::API::Twitter are introduced to provide basic services to your bots.
119
+
120
+ ```
121
+ verbose: true
122
+
123
+ bot:
124
+ name: dummy-bot
125
+ version: 1.0
126
+ email: paolo@codesake.com
127
+ behaviour: dummy-bot.rb
128
+
129
+ twitter:
130
+ enabled: yes
131
+ accounts:
132
+ - { name: first, consumer_key: "AAAAAAAAAAAAAAAAAAAAAAAAA", consumer_secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", oauth_token: "999999999-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", oauth_token_secret: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"}
133
+ - { name: second, consumer_key: "AAAAAAAAAAAAAAAAAAAAAAAAA", consumer_secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", oauth_token: "999999999-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", oauth_token_secret: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"}
134
+ - { name: third, consumer_key: "AAAAAAAAAAAAAAAAAAAAAAAAA", consumer_secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", oauth_token: "999999999-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", oauth_token_secret: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"}
135
+
136
+ task:
137
+ - { schedule: every 5 s, action: say_hello }
138
+ - { schedule: every 3 s, action: say_foo }
139
+ - { schedule: every 1 h, action: tweet_hello }
140
+ ```
141
+
142
+ The tweet\_hello routine is very simple:
143
+
144
+ ```
145
+ def tweet_hello
146
+ return "" if $twitter_api.nil?
147
+ $twitter_api.tweet("hello")
148
+ end
149
+ ```
150
+
151
+ The ```$twitter_api``` ojeect is something provided by the engine, so it's very
152
+ easy for bot writers.
153
+
113
154
  ## Missing features
114
155
 
115
- A back channels for threads to communicate with the engine about action
116
- status.
156
+ Other social network integration, mainly facebook.
117
157
 
118
158
  ## Contributing
119
159
 
data/bin/botolo CHANGED
@@ -3,6 +3,13 @@
3
3
  require 'botolo'
4
4
  require 'openssl'
5
5
  require 'codesake-commons'
6
+ require 'getoptlong'
7
+
8
+ opts = GetoptLong.new(
9
+ [ "--debug", "-D", GetoptLong::NO_ARGUMENT],
10
+ [ "--help", "-h", GetoptLong::NO_ARGUMENT],
11
+ [ "--version", "-v", GetoptLong::NO_ARGUMENT ]
12
+ )
6
13
 
7
14
  DEFAULT_BEHAVIOUR = "./lib/botolo/bot/behaviour.rb"
8
15
  BOTOLO_PID = File.join(".", "botolo.pid")
@@ -10,8 +17,29 @@ BOTOLO_PID = File.join(".", "botolo.pid")
10
17
  OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE
11
18
 
12
19
  $logger = Codesake::Commons::Logging.instance
13
- $logger.filename="./codesake-bot.log"
14
- trap("INT") { @bot.stop; $logger.bye; Kernel.exit(0); }
20
+ trap("INT") { @bot.stop; $logger.bye; File.delete(BOTOLO_PID); Kernel.exit(0); }
21
+
22
+ opts.quiet=true
23
+ debug = false
24
+
25
+ begin
26
+ opts.each do |opt, val|
27
+ case opt
28
+ when '--version'
29
+ puts "#{Botolo::VERSION}"
30
+ Kernel.exit(0)
31
+ when '--help'
32
+ puts "I'll put an help here... promise"
33
+ Kernel.exit(0)
34
+ when '--debug'
35
+ debug = true
36
+ end
37
+ end
38
+ rescue GetoptLong::InvalidOption => e
39
+ $logger.helo "botolo", Botolo::VERSION, BOTOLO_PID
40
+ $logger.err e.message
41
+ Kernel.exit(-1)
42
+ end
15
43
 
16
44
  behaviour_file = DEFAULT_BEHAVIOUR
17
45
  config_file = nil
@@ -22,10 +50,15 @@ $logger.die "usage: botolo bot_configuration_file" if config_file.nil?
22
50
  $logger.helo "botolo", Botolo::VERSION, BOTOLO_PID
23
51
 
24
52
  @bot = Botolo::Bot::Engine.new({:config=>config_file})
25
- $logger.log "#{@bot.name} is online" if @bot.online?
53
+ $logger.log "#{@bot.name} is online" if @bot.online?
26
54
  $logger.log "#{@bot.name} is offline" unless @bot.online?
27
- @bot.run if @bot.online?
28
- # Process.daemon(true, true)
55
+
56
+ if debug
57
+ $logger.debug "forcing #{@bot.name} run"
58
+ @bot.run
59
+ else
60
+ @bot.run if @bot.online?
61
+ end
29
62
  @bot.infinite_loop
30
63
 
31
64
 
data/botolo.gemspec CHANGED
@@ -23,6 +23,6 @@ Gem::Specification.new do |spec|
23
23
 
24
24
  spec.add_development_dependency "bundler", "~> 1.3"
25
25
  spec.add_development_dependency "rake"
26
- spec.add_dependency "twitter"
27
- spec.add_dependency "codesake-commons"
26
+ spec.add_dependency "twitter", "~> 5.11.0"
27
+ spec.add_dependency "codesake-commons", "~> 1.0.0"
28
28
  end
@@ -0,0 +1,15 @@
1
+ verbose: true
2
+
3
+ bot:
4
+ name: dummy-bot
5
+ version: 1.0
6
+ email: paolo@codesake.com
7
+ logfile: dummy_log.log
8
+ behaviour: dummy_bot.rb
9
+
10
+ twitter:
11
+ enabled: no
12
+
13
+ task:
14
+ - { schedule: every 5 s, action: say_hello }
15
+ - { schedule: every 3 s, action: say_foo }
@@ -0,0 +1,22 @@
1
+ module Botolo
2
+ module Bot
3
+ class Behaviour
4
+ def initialize(options={})
5
+ end
6
+
7
+ def say_hello
8
+ puts "hello"
9
+ end
10
+
11
+ def say_foo
12
+ puts "foo"
13
+ end
14
+
15
+ def tweet_hello
16
+ return "" if $twitter_api.nil?
17
+ $twitter_api.tweet("hello")
18
+ end
19
+ end
20
+ end
21
+ end
22
+
@@ -0,0 +1,19 @@
1
+ verbose: true
2
+
3
+ bot:
4
+ name: dummy-bot
5
+ version: 1.0
6
+ email: paolo@codesake.com
7
+ behaviour: dummy_bot.rb
8
+
9
+ twitter:
10
+ enabled: yes
11
+ accounts:
12
+ - {name: first, consumer_key: "AAAAAAAAAAAAAAAAAAAAAAAAA", consumer_secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", oauth_token: "999999999-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", oauth_token_secret: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"}
13
+ - {name: second, consumer_key: "AAAAAAAAAAAAAAAAAAAAAAAAA", consumer_secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", oauth_token: "999999999-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", oauth_token_secret: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"}
14
+ - {name: third, consumer_key: "AAAAAAAAAAAAAAAAAAAAAAAAA", consumer_secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", oauth_token: "999999999-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", oauth_token_secret: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"}
15
+
16
+ task:
17
+ - { schedule: every 5 s, action: say_hello }
18
+ - { schedule: every 3 s, action: say_foo }
19
+ - { schedule: every 1 h, action: tweet_hello }
data/lib/botolo.rb CHANGED
@@ -1,2 +1,4 @@
1
1
  require "botolo/version"
2
+ require "botolo/api/blog"
3
+ require "botolo/api/tweet"
2
4
  require "botolo/bot/engine"
@@ -0,0 +1,57 @@
1
+ require 'rss'
2
+
3
+ module Botolo
4
+ module API
5
+ class Blog
6
+
7
+ def initialize(options={})
8
+ @url = options[:url]
9
+ @tweet = options[:tweet_api]
10
+ end
11
+
12
+ def refresh_rss
13
+ rss = nil
14
+
15
+ open("#{@url}/feed.xml") do |http|
16
+ response = http.read
17
+ rss = RSS::Parser.parse(response, false)
18
+ end
19
+ @feed = []
20
+ rss.items.each_with_index do |item, i|
21
+ @feed << {:title=>item.title.content, :link=>item.link.href}
22
+ end
23
+ $logger.log "#{@feed.size} elements loaded from feed"
24
+ end
25
+
26
+ def tweet_random_posts(limit = 3, hashtags="")
27
+ return nil if @feed.nil? || @feed.size == 0
28
+ (0..limit-1).each do |l|
29
+ post = @feed[SecureRandom.random_number(@feed.size)]
30
+ m = "\"#{post[:title]}\" (#{post[:link]}) #{hashtags}"
31
+ $logger.debug "#{m} - #{m.length}"
32
+ begin
33
+ @tweet.tweet(m)
34
+ $logger.debug "tweet sent!"
35
+ rescue => e
36
+ $logger.err("error tweeting #{m}: #{e.message}")
37
+ end
38
+ sleep(10)
39
+
40
+ end
41
+ end
42
+
43
+ def promote_latest(hashtags="")
44
+ return nil if @feed.nil? || @feed.size == 0
45
+ post = @feed[0]
46
+ m = "\"#{post[:title]}\" (#{post[:link]}) #blog #sicurezza #informatica."
47
+ $logger.debug "#{m} - #{m.length}"
48
+ begin
49
+ @tweet.tweet(m)
50
+ rescue => e
51
+ $logger.err("error tweeting #{m}: #{e.message}")
52
+ end
53
+ end
54
+
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,77 @@
1
+ require 'twitter'
2
+ require 'singleton'
3
+
4
+ module Botolo
5
+ module API
6
+ class Tweet
7
+
8
+ # twitters is a twitter client array read from configuration file and
9
+ # used to access the social network for a given account.
10
+ attr_reader :twitters
11
+ include Singleton
12
+
13
+ def authenticate(config)
14
+ @twitters = []
15
+ config['accounts'].each do |account|
16
+ a=Hash.new
17
+ a[:name] = account['name']
18
+ begin
19
+ a[:client] = Twitter::REST::Client.new do |config|
20
+ config.consumer_key = account['consumer_key']
21
+ config.consumer_secret = account['consumer_secret']
22
+
23
+ config.access_token = account['access_token'] unless account['access_token'].nil?
24
+ config.access_token_secret = account['access_token_secret'] unless account['access_token_secret'].nil?
25
+ end
26
+ rescue Exception => e
27
+ $logger.err e.message
28
+ end
29
+
30
+ @twitters << a
31
+ end
32
+
33
+ @twitters
34
+ end
35
+
36
+ def tweet(name=nil, msg)
37
+ return nil if msg.empty?
38
+ @twitters.each do |t|
39
+ t[:client].update(msg) if (name.nil? or (!name.nil? and name == t[:name]))
40
+ end
41
+ return msg
42
+ end
43
+
44
+ def retweet(name=nil, msg)
45
+ return nil if msg.empty?
46
+ @twitters.each do |t|
47
+ t[:client].retweet(msg) if (name.nil? or (!name.nil? and name == t[:name]))
48
+ end
49
+ return msg
50
+ end
51
+
52
+ def find_and_retweet_topic(limit = 5, topic)
53
+ list = []
54
+ @twitters.each do |tt|
55
+ list << tt[:client].search("#appsec").to_a
56
+ end
57
+
58
+ unless list.nil?
59
+ (0..limit-1).each do |l|
60
+ t = list[SecureRandom.random_number(list.count)]
61
+ $logger.debug "retwitting #{t["from_user"]}: #{t["text"]}"
62
+ begin
63
+ @twitters.each do |t|
64
+ t[:client].retweet(msg) if (name.nil? or (!name.nil? and name == t[:name]))
65
+ end
66
+ rescue => e
67
+ $logger.err("error tweeting #{t["text"]}: #{e.message}")
68
+ end
69
+ sleep(15)
70
+ end
71
+ end
72
+ end
73
+
74
+
75
+ end
76
+ end
77
+ end
@@ -1,4 +1,3 @@
1
- require 'twitter'
2
1
  require 'yaml'
3
2
 
4
3
  module Botolo
@@ -9,26 +8,36 @@ module Botolo
9
8
  @start_time = Time.now
10
9
  @online = false
11
10
  @config = read_conf(options[:config])
12
- $twitter_client = nil
13
- authenticate if @config['twitter']['enabled']
11
+ $twitter_api = nil
12
+
13
+ behaviour_path = File.dirname(options[:config])
14
+
15
+ if @config['twitter']['enabled']
16
+ $twitter_api = Botolo::API::Tweet.instance
17
+ $twitter_api.authenticate(@config['twitter'])
18
+ end
14
19
 
15
20
  @tasks = @config['task']
16
21
  @task_pids = []
17
22
 
18
- behaviour = File.join(".", @config['bot']['behaviour']) unless @config['bot']['behaviour'].nil?
23
+ behaviour = File.join(behaviour_path, @config['bot']['behaviour']) unless @config['bot']['behaviour'].nil?
19
24
 
20
25
  $logger.helo name, version
26
+ $logger.filename = File.join(".", logfile) unless logfile.nil?
27
+
21
28
  $logger.log "#{@tasks.size} tasks loaded"
22
29
 
23
30
  begin
24
31
  load behaviour
25
32
  $logger.log "using #{behaviour} as bot behaviour"
26
33
  @behaviour = Botolo::Bot::Behaviour.new(@config)
34
+ @start_time = Time.now
27
35
  rescue => e
28
36
  $logger.err(e.message)
29
37
  require 'botolo/bot/behaviour'
30
38
  $logger.log "reverting to default dummy behaviour"
31
39
  @behaviour = Botolo::Bot::Behaviour.new(@config)
40
+ @start_time = Time.now
32
41
  end
33
42
  end
34
43
 
@@ -57,11 +66,25 @@ module Botolo
57
66
 
58
67
  def infinite_loop
59
68
  loop do
60
- sleep(3600) # => 1 d
61
- $logger.log " --- mark --- "
69
+ sleep(3600) # => 1 h
70
+ $logger.log " --- mark --- (bot: #{@behaviour.name}, uptime: #{uptime})"
62
71
  end
63
72
  end
64
73
 
74
+ def uptime
75
+ seconds_diff = (Time.now - @start_time).to_i.abs
76
+
77
+ days = seconds_diff / 86400
78
+ seconds_diff -= days * 86400
79
+
80
+ hours = seconds_diff / 3600
81
+ seconds_diff -= hours * 3600
82
+
83
+ minutes = seconds_diff / 60
84
+
85
+ "#{days.to_s} days, #{hours.to_s.rjust(2, '0')}:#{minutes.to_s.rjust(2, '0')}"
86
+ end
87
+
65
88
  def start_task(name, sleep)
66
89
  while true
67
90
  begin
@@ -86,24 +109,14 @@ module Botolo
86
109
  true
87
110
  end
88
111
 
89
- def authenticate
90
- begin
91
- $twitter_client = Twitter::REST::Client.new do |config|
92
- config.consumer_key = @config['twitter']['consumer_key']
93
- config.consumer_secret = @config['twitter']['consumer_secret']
94
- config.oauth_token = @config['twitter']['oauth_token']
95
- config.oauth_token_secret = @config['twitter']['oauth_token_secret']
96
- end
97
- @online = true
98
- rescue Exception => e
99
- $logger.err e.message
100
- end
101
- end
102
-
103
112
  def online?
104
113
  @online
105
114
  end
106
115
 
116
+ def logfile
117
+ return @config['bot']['logfile']
118
+ end
119
+
107
120
  def name
108
121
  return @config['bot']['name']
109
122
  end
@@ -1,3 +1,3 @@
1
1
  module Botolo
2
- VERSION = "0.32.9"
2
+ VERSION = "0.50.0"
3
3
  end
metadata CHANGED
@@ -1,71 +1,71 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: botolo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.32.9
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paolo Perego
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-07-30 00:00:00.000000000 Z
11
+ date: 2014-12-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ~>
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
19
  version: '1.3'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - ~>
24
+ - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.3'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: rake
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - '>='
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: '0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
- - - '>='
38
+ - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: twitter
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - '>='
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0'
47
+ version: 5.11.0
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
- - - '>='
52
+ - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0'
54
+ version: 5.11.0
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: codesake-commons
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
- - - '>='
59
+ - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '0'
61
+ version: 1.0.0
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - '>='
66
+ - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '0'
68
+ version: 1.0.0
69
69
  description: botolo is a bot engine written in ruby
70
70
  email:
71
71
  - thesp0nge@gmail.com
@@ -74,14 +74,21 @@ executables:
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
- - .gitignore
77
+ - ".gitignore"
78
+ - ".ruby-gemset"
79
+ - ".ruby-version"
78
80
  - Gemfile
79
81
  - LICENSE.txt
80
82
  - README.md
81
83
  - Rakefile
82
84
  - bin/botolo
83
85
  - botolo.gemspec
86
+ - examples/config.yaml
87
+ - examples/dummy_bot.rb
88
+ - examples/dummy_multiple_twitters.yaml
84
89
  - lib/botolo.rb
90
+ - lib/botolo/api/blog.rb
91
+ - lib/botolo/api/tweet.rb
85
92
  - lib/botolo/bot/behaviour.rb
86
93
  - lib/botolo/bot/engine.rb
87
94
  - lib/botolo/version.rb
@@ -95,17 +102,17 @@ require_paths:
95
102
  - lib
96
103
  required_ruby_version: !ruby/object:Gem::Requirement
97
104
  requirements:
98
- - - '>='
105
+ - - ">="
99
106
  - !ruby/object:Gem::Version
100
107
  version: '0'
101
108
  required_rubygems_version: !ruby/object:Gem::Requirement
102
109
  requirements:
103
- - - '>='
110
+ - - ">="
104
111
  - !ruby/object:Gem::Version
105
112
  version: '0'
106
113
  requirements: []
107
114
  rubyforge_project:
108
- rubygems_version: 2.1.11
115
+ rubygems_version: 2.2.2
109
116
  signing_key:
110
117
  specification_version: 4
111
118
  summary: botolo is a bot engine written in ruby. With botolo you can focus on writing