talkshow 1.4.2

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0defe1121946da84d88bfc719622b659c34d5033
4
+ data.tar.gz: c3e5c0855a41a9e698e543dcf3d1599bc067a80e
5
+ SHA512:
6
+ metadata.gz: b3df5f71a04d10ceb8c07b713aff6de3c950c19ebbf1ac0606583ea34d94458de2a5a2cf22ff8cb40b42ec2594df659763342b08cdb3a5f91d4f33a5baef1784
7
+ data.tar.gz: 2fa658549eea26110bf46d37889768c44b1f6286ecffc790390c222d57cf164505292dd538710c4ecedcdd07dbe8e6c0785b73ed95f9c4c887c6a2d149407c84
@@ -0,0 +1,241 @@
1
+ require 'net/http'
2
+ require 'thread'
3
+ require 'json'
4
+ require 'talkshow/server'
5
+ require 'talkshow/timeout'
6
+ require 'talkshow/javascript_error'
7
+ require 'talkshow/queue'
8
+
9
+
10
+ #Main class for talking to a talkshow instrumented js application.
11
+ #
12
+ #This is the only class you need to worry about, and there are only
13
+ #a few important methods.
14
+ #
15
+ # Create the Talkshow client object:
16
+ # ts = Talkshow.new()
17
+ # Start up the server
18
+ # ts.start_server()
19
+ # Start executing javascript:
20
+ # ts.execute( 'alert "Hello world!"' )
21
+ class Talkshow
22
+ attr_accessor :type
23
+ attr_accessor :thread
24
+
25
+ # Create a new Talkshow object to get going:
26
+ def initialize
27
+ end
28
+
29
+ # Start up the Talkshow webserver
30
+ # This will be triggered if you don't do it -- but it takes a few
31
+ # seconds to start up the thin server, so you are better off
32
+ # issuing this yourself
33
+ def start_server(options = {})
34
+
35
+ # Backward compatibility
36
+ if options.is_a? String
37
+ url = options
38
+ port = nil
39
+ logfile = nil
40
+ else
41
+ url = options[:url]
42
+ port = options[:port]
43
+ logfile = options[:logfile]
44
+ end
45
+
46
+ url = ENV['TALKSHOW_REMOTE_URL'] if ENV['TALKSHOW_REMOTE_URL']
47
+ port = ENV['TALKSHOW_PORT'] if ENV['TALKSHOW_PORT']
48
+ logfile = ENV['TALKSHOW_LOG'] if ENV['TALKSHOW_LOG']
49
+
50
+ Talkshow::Server.set_port port if port
51
+ Talkshow::Server.set_logfile logfile if logfile
52
+
53
+ if !url
54
+ @type = :thread
55
+ @question_queue = ::Queue.new
56
+ @answer_queue = ::Queue.new
57
+ @thread = Thread.new do
58
+ Talkshow::Server.question_queue(@question_queue)
59
+ Talkshow::Server.answer_queue(@answer_queue)
60
+ Talkshow::Server.run!
61
+ end
62
+ else
63
+ @type = :remote
64
+ @question_queue = Talkshow::Queue.new(url)
65
+ @answer_queue = Talkshow::Queue.new(url)
66
+ end
67
+
68
+ end
69
+
70
+ # Stop the webserver
71
+ def stop_server
72
+ @thread.exit
73
+ end
74
+
75
+ # Invoke a function in the javascript application
76
+ # invoke requires a function name (including the namespace).
77
+ # Arguments are specified as an array reference.
78
+ #
79
+ # Some examples:
80
+ # ts.invoke( 'alert' )
81
+ # ts.invoke( 'alert', ['Hello world'])
82
+ # ts.invoke( 'window.alert', ['Hello world'] )
83
+ def invoke( function, args, timeout=6 )
84
+ send_question( {
85
+ type: 'invocation',
86
+ function: function,
87
+ args: args
88
+ }, timeout)
89
+ end
90
+
91
+ # Send a javascript instruction to the client
92
+ def execute( command, timeout=6 )
93
+ send_question( { type: 'code', message: command }, timeout)
94
+ end
95
+
96
+ # Load in a javascript file and execute remotely
97
+ def execute_file( filename )
98
+ text = File.read(filename)
99
+ execute(text)
100
+ end
101
+
102
+ private
103
+
104
+ def soft_pop
105
+ begin
106
+ @answer_queue.pop(true)
107
+ rescue => e
108
+ nil
109
+ end
110
+ end
111
+
112
+ def non_blocking_pop(timeout)
113
+ sleep_time = 0.1
114
+ answer = nil
115
+ catch(:done) do
116
+ (timeout/sleep_time).to_i.times { |i|
117
+ answer = soft_pop
118
+ throw :done if answer
119
+ sleep sleep_time
120
+ }
121
+ end
122
+ answer
123
+ end
124
+
125
+
126
+ # listen for an answer for a specific id, with a timeout, and also reconstitute
127
+ # any chunked responses
128
+ def listen_for_answer(id, timeout)
129
+
130
+ if ENV['TIMEOUT_MULTIPLIER']
131
+ timeout = ENV['TIMEOUT_MULTIPLIER'].to_i * timeout
132
+ end
133
+
134
+ answer = non_blocking_pop(timeout)
135
+ if !answer
136
+ raise Talkshow::Timeout.new
137
+ end
138
+
139
+ mismatch_retry = 3
140
+ if answer[:id].to_i != id.to_i && mismatch_retry >= 0
141
+ puts "Talkshow warning: message mismatch (#{answer[:id]} vs #{id})" unless answer[:id].to_i == 0
142
+ answer = non_blocking_pop(timeout)
143
+ mismatch_retry -= 1
144
+ end
145
+
146
+ if !answer
147
+ raise Talkshow::Timeout.new
148
+ end
149
+
150
+ chunks = answer[:chunks]
151
+ if chunks
152
+ answers = [answer]
153
+
154
+ i = 1
155
+ nil_count = 0
156
+ while ( i < chunks.to_i && nil_count < 3 ) do
157
+ candidate = non_blocking_pop(1)
158
+ if !candidate
159
+ nil_count += 1
160
+ next
161
+ end
162
+ if candidate[:id].to_i != id.to_i
163
+ puts "Talkshow warning: message mismatch (#{candidate[:id]} vs #{id.to_i})"
164
+ next
165
+ end
166
+
167
+ nil_count = 0
168
+ i += 1
169
+ answers << candidate
170
+ end
171
+
172
+ if answers.count < chunks.to_i
173
+ raise "Couldn't reconstitute whole message"
174
+ end
175
+
176
+ sorted_answers = answers.sort_by{ |a| a[:payload].to_i }
177
+ data = sorted_answers.collect { |a| a[:data] }.join
178
+ answer[:data] = data
179
+ answer[:payload] = nil
180
+ end
181
+
182
+ answer
183
+ end
184
+
185
+
186
+ # Send message to js application
187
+ # Message is a hash that looks like:
188
+ # {
189
+ # type => message_type,
190
+ # message => command,
191
+ # }
192
+ # Timeout is optional, but a negative timeout returns without
193
+ # looking for an answer
194
+ def send_question( message, timeout )
195
+
196
+ # Start the server if it hasn't been started already
197
+ self.start_server if (self.type == :thread && !self.thread)
198
+
199
+ @answer_queue.clear();
200
+ message[:id] = rand(99999)
201
+
202
+ @question_queue.push( message )
203
+
204
+ # Negative timeout - fire and forget
205
+ # Should only be used if it is known not to return an answer
206
+ return nil if timeout < 0
207
+
208
+ answer = listen_for_answer(message[:id], timeout)
209
+
210
+ if answer[:status] == 'error'
211
+ raise Talkshow::JavascriptError.new( answer[:data] )
212
+ end
213
+
214
+ case answer[:object]
215
+ when 'boolean'
216
+ answer[:data] == 'true'
217
+ when 'number'
218
+ if answer[:data].include?('.')
219
+ answer[:data].to_f
220
+ else
221
+ answer[:data].to_i
222
+ end
223
+ when 'undefined'
224
+ if answer[:data] == 'undefined'
225
+ nil
226
+ else
227
+ answer[:data]
228
+ end
229
+ when 'string'
230
+ answer[:data].to_s
231
+ else
232
+ begin
233
+ JSON.parse(answer[:data])
234
+ rescue StandardError => e
235
+ answer[:data]
236
+ end
237
+ end
238
+ end
239
+
240
+ end
241
+
@@ -0,0 +1,99 @@
1
+ require 'net/http'
2
+ require "uri"
3
+
4
+ require 'thread'
5
+ require 'json'
6
+ require 'daemon'
7
+
8
+ require 'talkshow'
9
+ require 'talkshow/web_control'
10
+ require 'talkshow/server'
11
+
12
+ class Talkshow::Daemon
13
+ attr_accessor :thread
14
+ attr_accessor :port_requests
15
+ attr_accessor :processes
16
+
17
+ # Create a new Talkshow object to get going
18
+ def initialize
19
+ Dir.mkdir './logs' if !Dir.exists?('./logs')
20
+ Dir.mkdir './pids' if !Dir.exists?('./pids')
21
+ @processes = {}
22
+ @port_requests = ::Queue.new
23
+ end
24
+
25
+ def start_server
26
+ @thread = Thread.new do
27
+ Talkshow::WebControl.port_requests(@port_requests)
28
+ Talkshow::WebControl.processes(@processes)
29
+ Talkshow::WebControl.run!
30
+ end
31
+ p @thread
32
+ sleep 10
33
+ end
34
+
35
+ # Stop the webserver
36
+ def stop_server
37
+ @thread.exit
38
+ end
39
+
40
+ def run
41
+ self.start_server
42
+ loop do
43
+ deal_with_port_requests
44
+ sleep 5
45
+ check_processes
46
+ end
47
+ end
48
+
49
+ def deal_with_port_requests
50
+ begin
51
+ port = @port_requests.pop(true)
52
+ rescue
53
+ port = nil
54
+ end
55
+ if port
56
+ if @processes[port]
57
+ puts "Port request -- checking aliveness"
58
+ if check_status(port) == 'dead'
59
+ @processes[port] = spawn_process(port)
60
+ end
61
+ else
62
+ puts "New port request"
63
+ @processes[port] = spawn_process(port)
64
+ end
65
+ end
66
+ end
67
+
68
+ def spawn_process(port)
69
+ `TALKSHOW_PORT=#{port} bundle exec ./bin/talkshow_server.rb > logs/talkshow.#{port}.log 2>&1 &`
70
+ sleep 5
71
+ 'starting'
72
+ end
73
+
74
+ def check_status(port)
75
+ uri = URI.parse("http://localhost:#{port}/status")
76
+ begin
77
+ response = Net::HTTP.get_response(uri)
78
+ rescue
79
+ status = 'dead'
80
+ end
81
+
82
+ if !status
83
+ if response.code.to_i == 200
84
+ status = 'ok'
85
+ else
86
+ status = "dead #{response.code}"
87
+ end
88
+ end
89
+ status
90
+ end
91
+
92
+ def check_processes()
93
+ @processes.each do |port, status|
94
+ @processes[port] = check_status(port)
95
+ end
96
+ end
97
+
98
+ end
99
+
@@ -0,0 +1,4 @@
1
+ class Talkshow
2
+ class Talkshow::JavascriptError < StandardError
3
+ end
4
+ end
@@ -0,0 +1,32 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require 'json'
4
+
5
+ class Talkshow::Queue
6
+ attr_accessor :url
7
+
8
+ def initialize(url)
9
+ @uri = URI.parse(url)
10
+ @http = Net::HTTP.new(@uri.host, @uri.port)
11
+ end
12
+
13
+ def clear
14
+ response = @http.request(Net::HTTP::Get.new('/answerqueue/clear'))
15
+ response
16
+ end
17
+
18
+ def pop(ignored)
19
+ response = @http.request(Net::HTTP::Get.new('/answerqueue/pop'))
20
+ object = JSON.parse(response.body, :symbolize_names => true)
21
+ object[:message]
22
+ end
23
+
24
+ def push(obj)
25
+ serialized_object = obj.to_json.to_s
26
+ request = Net::HTTP::Post.new('/questionqueue/push')
27
+ request.set_form_data( {'message' => serialized_object } )
28
+ response = @http.request(request)
29
+ nil
30
+ end
31
+
32
+ end
@@ -0,0 +1,218 @@
1
+ require 'sinatra/base'
2
+ require 'net/http'
3
+ require 'thread'
4
+ require 'json'
5
+ require 'logger'
6
+
7
+
8
+ class Queue
9
+ # Take a peek at what's in the array
10
+ def peek
11
+ @que
12
+ end
13
+ end
14
+
15
+ # Sinatra server that is launched by your test code
16
+ # to talk to the instrumented javascript application
17
+ class Talkshow
18
+ class Talkshow::Server < Sinatra::Base
19
+ configure do
20
+ set :port, ENV['TALKSHOW_PORT'] if ENV['TALKSHOW_PORT']
21
+ set :protection, except: :path_traversal
22
+ end
23
+
24
+ def self.set_port port
25
+ set :port, port
26
+ end
27
+
28
+ @@logfile = './talkshowserver.log'
29
+ def self.set_logfile file
30
+ @@logfile = file
31
+ @logger.close if @logger
32
+ @logger = nil
33
+ end
34
+
35
+ def self.question_queue(queue = nil)
36
+ if queue
37
+ @@question_queue = queue
38
+ end
39
+ @@question_queue
40
+ end
41
+
42
+ def self.answer_queue(queue = nil)
43
+ if queue
44
+ @@answer_queue = queue
45
+ end
46
+ @@answer_queue
47
+ end
48
+
49
+
50
+ def logger
51
+ if !@logger
52
+ @logger = Logger.new(@@logfile)
53
+ end
54
+ @logger
55
+ end
56
+
57
+ # Make this available externally
58
+ set :bind, '0.0.0.0'
59
+
60
+ get '/' do
61
+ questions = Talkshow::Server.question_queue.peek.to_json
62
+ answers = Talkshow::Server.answer_queue.peek.to_json
63
+
64
+ <<HERE
65
+ <html>
66
+ <body>
67
+ <div>
68
+ <h1>Talkshow process: #{$$}</h1>
69
+ <h2>Port: #{settings.port}</h2>
70
+ </div>
71
+ <div>
72
+ <p>#{questions}</p>
73
+ </div>
74
+ <div>
75
+ <p>#{answers}</p>
76
+ </div>
77
+ </body>
78
+ </html>
79
+ HERE
80
+ end
81
+
82
+ get '/status' do
83
+ 200
84
+ end
85
+
86
+ get '/talkshowhost' do
87
+ "Talkshow running on " + request.host.to_s
88
+ end
89
+
90
+ get '/question/:poll_id' do
91
+ t = Time.new()
92
+
93
+ json_hash = {
94
+ :time => t.to_s,
95
+ }
96
+
97
+ content = nil;
98
+ if Talkshow::Server.question_queue.empty?
99
+ logger.debug("question: nop")
100
+ json_hash[:type] = "nop"
101
+ json_hash[:message] = ""
102
+
103
+ else
104
+ content = Talkshow::Server.question_queue.pop if !Talkshow::Server.question_queue.empty?
105
+ id = content[:id]
106
+ json_hash[:id] = id
107
+ logger.info( "question ##{id}: #{content.to_s}" )
108
+
109
+ type = content[:type]
110
+ json_hash[:type] = type
111
+
112
+ if type == 'code'
113
+ json_hash[:content] = content[:message]
114
+ elsif type == 'invocation'
115
+ json_hash[:function] = content[:function]
116
+ json_hash[:args] = content[:args]
117
+ end
118
+ end
119
+
120
+ callback = params[:callback]
121
+
122
+ json = json_hash.to_json
123
+
124
+ logger.info( json )
125
+
126
+ if callback
127
+ content_type 'text/javascript'
128
+ "#{callback}( #{json} );"
129
+ else
130
+ content_type :json
131
+ json
132
+ end
133
+ end
134
+
135
+ # Deal with an answer, push it back to the main thread
136
+ def handle_answer(params, data)
137
+ if params[:status] != 'nop'
138
+
139
+ Talkshow::Server.answer_queue.push( {
140
+ :data => data,
141
+ :object => params[:object],
142
+ :status => params[:status],
143
+ :chunks => params[:chunks],
144
+ :payload => params[:payload],
145
+ :id => params[:id]
146
+ } )
147
+ end
148
+
149
+ logger.info( "/answer ##{params[:id]}"+ ( params[:chunks] ? "(#{params[:payload].to_i+1}/#{params[:chunks]})" : '') +": #{data}" )
150
+ if params[:id] == 0
151
+ logger.info( "Reset received, talkshow reloaded")
152
+ end
153
+
154
+ content_type 'text/javascript'
155
+ 'ts.ack();'
156
+ end
157
+
158
+ # Capture an answer
159
+ get '/answer/:poll_id/:id/:status/:object/:data' do
160
+ handle_answer(params, params[:data])
161
+ end
162
+
163
+ # Capture the case when a response has no data (empty string)
164
+ get '/answer/:poll_id/:id/:status/:object/' do
165
+ handle_answer(params, '')
166
+ end
167
+
168
+ # Capture older talkshow.js implementations that didn't escape urls properly
169
+ get '/answer/:poll_id/:id/:status/:object/*' do
170
+ logger.warn("WARNING: Unescaped url passed as data component for route '#{request.fullpath}'")
171
+ data = params[:splat].join('/')
172
+ handle_answer(params, data)
173
+ end
174
+
175
+ # Functions for remotely clearing queues
176
+ get '/answerqueue/clear' do
177
+ Talkshow::Server.answer_queue.clear()
178
+ end
179
+
180
+ get '/questionqueue/clear' do
181
+ Talkshow::Server.question_queue.clear()
182
+ end
183
+
184
+ # Push something onto the answer queue remotely
185
+ post '/questionqueue/push' do
186
+ logger.info( '/questionqueue/push' )
187
+ message = JSON.parse(params[:message], :symbolize_names => true)
188
+ logger.debug("Message pushed: #{message}")
189
+ Talkshow::Server.question_queue.push(message)
190
+ end
191
+
192
+ # Pop something from the answer queue
193
+ get '/answerqueue/pop' do
194
+ logger.info('answerqueue/pop')
195
+ begin
196
+ message = Talkshow::Server.answer_queue.pop(true)
197
+ rescue
198
+ message = nil
199
+ end
200
+ { :message => message }.to_json
201
+ end
202
+
203
+ get '/questionqueue' do
204
+ Talkshow::Server.question_queue.peek.to_json
205
+ end
206
+
207
+ get '/answerqueue' do
208
+ Talkshow::Server.answer_queue.peek.to_json
209
+ end
210
+
211
+ # Catch anything else and shout about it
212
+ get '/*' do
213
+ puts "[Talkshow server warning] Unhandled route: '#{request.fullpath}'"
214
+ logger.error("WARNING: Unhandled route: '#{request.fullpath}'")
215
+ end
216
+
217
+ end
218
+ end
@@ -0,0 +1,4 @@
1
+ class Talkshow
2
+ class Talkshow::Timeout < StandardError
3
+ end
4
+ end
@@ -0,0 +1,85 @@
1
+ require 'sinatra/base'
2
+ require 'net/http'
3
+ require 'thread'
4
+ require 'json'
5
+ require 'logger'
6
+
7
+ require 'talkshow/server'
8
+ require 'thread'
9
+
10
+
11
+
12
+ class Talkshow
13
+ class Talkshow::WebControl < Sinatra::Base
14
+
15
+ set :bind, '0.0.0.0'
16
+ configure do
17
+ set :port, ENV['WEB_CONTROLLER_PORT']
18
+ end
19
+
20
+ # Thread safe
21
+ def self.port_requests(queue = nil)
22
+ if queue
23
+ @@port_requests = queue
24
+ end
25
+ @@port_requests
26
+ end
27
+
28
+ # Read-only, not thread safe
29
+ def self.processes(hash)
30
+ if hash
31
+ @@processes = hash
32
+ end
33
+ @@processes
34
+ end
35
+
36
+
37
+ def logger
38
+ if !@logger
39
+ @logger = Logger.new('talkshow_webcontrol.log')
40
+ end
41
+ @logger
42
+ end
43
+
44
+ get '/' do
45
+
46
+ process_table = "<table>"
47
+ @@processes.each do |port, status|
48
+ process_table += "<tr><td>#{port}</td><td>#{status}</td></tr>"
49
+ end
50
+ process_table += "</table>"
51
+
52
+ <<HERE
53
+ <html>
54
+ <style>
55
+ html { height: 100%;}
56
+ body {background: #CCC; font-family: Arial, Helvetica, sans-serif; padding: 20px;}
57
+ </style>
58
+ <body>
59
+ <div>
60
+ <h1>Talkshow web control</h1>
61
+ <h2>PID: #{$$}</h2>
62
+ <h2>Port: #{settings.port}</h2>
63
+ </div>
64
+ <div>
65
+ <h2>Active Processes</h2>
66
+ #{process_table}
67
+ </div>
68
+ </body>
69
+ </html>
70
+ HERE
71
+ end
72
+
73
+ get '/port/:port' do
74
+ port = params[:port].to_i
75
+ if port > 4000
76
+ Talkshow::WebControl.port_requests.push(port)
77
+ end
78
+ end
79
+
80
+ get '/status' do
81
+ 200
82
+ end
83
+
84
+ end
85
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: talkshow
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.4.2
5
+ platform: ruby
6
+ authors:
7
+ - Joseph Haig
8
+ - David Buckhurst
9
+ - Jenna Brown
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2016-04-01 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: sinatra
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '0'
29
+ - !ruby/object:Gem::Dependency
30
+ name: thin
31
+ requirement: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ - !ruby/object:Gem::Dependency
44
+ name: json
45
+ requirement: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ description: Ruby to Javascript communications bridge
58
+ email: joe.haig@bbc.co.uk
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - lib/talkshow.rb
64
+ - lib/talkshow/daemon.rb
65
+ - lib/talkshow/javascript_error.rb
66
+ - lib/talkshow/queue.rb
67
+ - lib/talkshow/server.rb
68
+ - lib/talkshow/timeout.rb
69
+ - lib/talkshow/web_control.rb
70
+ homepage: https://github.com/fmtvp/talkshow
71
+ licenses:
72
+ - MIT
73
+ metadata: {}
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubyforge_project:
90
+ rubygems_version: 2.5.0
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: Talkshow ruby gem
94
+ test_files: []