tinyirc 0.1.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.
@@ -0,0 +1,429 @@
1
+ @webaddr = "http://#{ENV['PUBLIC_HOST'] || ENV['HOST'] || '0.0.0.0'}:#{ENV['PUBLIC_PORT'] || ENV['PORT'] || 8080}"
2
+
3
+ #
4
+ # Helper methods
5
+ #
6
+
7
+ self.define_singleton_method :handle_event do |e|
8
+ def notify_plugin(plugin, e)
9
+ (plugin.event_handlers[e[:type]] || []).each do |h|
10
+ begin
11
+ h[:handler].(e) if e >= h[:pattern]
12
+ rescue => e
13
+ e.backtrace.each do |l|
14
+ @log.error "- #{l}"
15
+ end
16
+ @log.error "#{e.class.name} - #{e.message}"
17
+ end
18
+ end
19
+ end
20
+
21
+ notify_plugin(self, e)
22
+
23
+ @bot.plugins.each do |pname, plugin|
24
+ next if plugin == self
25
+ Thread.new do
26
+ notify_plugin(plugin, e)
27
+ end
28
+ end
29
+ end
30
+
31
+ #
32
+ # Event handlers
33
+ #
34
+
35
+ # Connect
36
+ on :connect do |e|
37
+ socket = e[:socket]
38
+ socket.log.important 'Connected'
39
+ socket.reconnects = 0
40
+ end
41
+
42
+ # Disconnect
43
+ on :disconnect do |e|
44
+ socket = e[:socket]
45
+ socket.sock.close if socket.sock
46
+ socket.log.important 'Disconnected'
47
+ if (socket.reconnects < 5)
48
+ socket.log.important 'Reconnecting in 5 seconds'
49
+ sleep 5
50
+ socket.reconnects += 1
51
+ socket.connect
52
+ else
53
+ socket.log.important 'Reconnect limit reached'
54
+ end
55
+ end
56
+
57
+ rgx_ping = /^PING :(.+)$/i
58
+ rgx_code = /^:.+? (\d\d\d) .+? (.+)$/i
59
+ rgx_join = /^:(.+?)!(.+?)@(.+?) JOIN (.+)$/i
60
+ rgx_part = /^:(.+?)!(.+?)@(.+?) PART (.+?) :(.+)$/i
61
+ rgx_nick = /^:(.+?)!(.+?)@(.+?) NICK :(.+)$/i
62
+ rgx_privmsg = /^:(.+?)!(.+?)@(.+?) PRIVMSG (.+?) :(.+)$/i
63
+ rgx_notice = /^:(.+?)!(.+?)@(.+?) NOTICE (.+?) :(.+)$/i
64
+
65
+ # Raw
66
+ on :raw do |e|
67
+ d = e[:raw_data]
68
+ if m = rgx_ping.match(d)
69
+ self.handle_event(e.merge!(type: :ping, target: m[1]))
70
+ elsif m = rgx_code.match(d)
71
+ self.handle_event(e.merge!(type: :code, code: m[1].to_i, extra: m[2]))
72
+ elsif m = rgx_join.match(d)
73
+ self.handle_event(e.merge!(
74
+ type: :join,
75
+ nick: m[1],
76
+ user: m[2],
77
+ host: m[3],
78
+ channel: m[4]
79
+ ))
80
+ elsif m = rgx_part.match(d)
81
+ self.handle_event(e.merge!(
82
+ type: :part,
83
+ nick: m[1],
84
+ user: m[2],
85
+ host: m[3],
86
+ channel: m[4],
87
+ reason: m[5]
88
+ ))
89
+ elsif m = rgx_nick.match(d)
90
+ self.handle_event(e.merge!(
91
+ type: :nick,
92
+ nick: m[1],
93
+ user: m[2],
94
+ host: m[3],
95
+ new_nick: m[4]
96
+ ))
97
+ elsif m = rgx_privmsg.match(d)
98
+ self.handle_event(e.merge!(
99
+ type: :privmsg,
100
+ nick: m[1],
101
+ user: m[2],
102
+ host: m[3],
103
+ target: m[4],
104
+ message: m[5],
105
+ reply_to: if m[4] == e[:socket].nick then m[1] else m[4] end
106
+ ))
107
+ elsif m = rgx_notice.match(d)
108
+ self.handle_event(e.merge!(
109
+ type: :privmsg,
110
+ nick: m[1],
111
+ user: m[2],
112
+ host: m[3],
113
+ target: m[4],
114
+ message: m[5],
115
+ reply_to: if m[4] == e[:socket].nick then m[1] else m[4] end
116
+ ))
117
+ end
118
+ end
119
+
120
+ # Ping
121
+ on :ping do |e|
122
+ e[:socket].write "PONG :#{e[:target]}"
123
+ end
124
+
125
+ # Welcome Code
126
+ on :code, code: 001 do |e|
127
+ s = e[:socket]
128
+ s.autojoin.each do |chan|
129
+ s.join chan
130
+ end
131
+ end
132
+
133
+ # WHOREPLY code
134
+ rgx_whoreply = /^.+? (.+?) (.+?) .+? (.+?) .*$/
135
+ on :code, code: 352 do |e|
136
+ m = rgx_whoreply.match(e[:extra])
137
+ if m
138
+ u = e[:socket].usercache.get(m[3])
139
+ u[:user] = m[1]
140
+ u[:host] = m[2]
141
+ end
142
+ end
143
+
144
+ # Join
145
+ on :join do |e|
146
+ s = e[:socket]
147
+ if e[:nick] == s.nick
148
+ s.write "WHO #{e[:channel]}"
149
+ end
150
+
151
+ u = s.usercache.get(e[:nick])
152
+ u[:user] = e[:user]
153
+ u[:host] = e[:host]
154
+ end
155
+
156
+ # Nick
157
+ on :nick do |e|
158
+ s = e[:socket]
159
+ if e[:nick] == s.nick
160
+ s.nick = e[:new_nick]
161
+ end
162
+
163
+ u = s.usercache.rename(e[:nick])
164
+ u[:user] = e[:user]
165
+ u[:host] = u[:host]
166
+ end
167
+
168
+ # PRIVMSG
169
+ on :privmsg do |e|
170
+ s = e[:socket]
171
+
172
+ u = s.usercache.get(e[:nick])
173
+ u[:user] = e[:user]
174
+ u[:host] = e[:host]
175
+
176
+ if e[:message][0, s.prefix.length] == s.prefix
177
+ s = Shellwords.split(e[:message][s.prefix.length, e[:message].length-1])
178
+ e[:type] = :cmd
179
+ e[:cmd] = s[0]
180
+
181
+ c = s[0].split('/', 3)
182
+ if c.length == 1
183
+ e[:cmd_info] = { plugin: :any, command: c[0], branch: :any }
184
+ elsif c.length == 2
185
+ e[:cmd_info] = { plugin: c[0], command: c[1], branch: :any }
186
+ elsif c.length == 3
187
+ e[:cmd_info] = { plugin: c[0], command: c[1], branch: c[2] }
188
+ else
189
+ raise RuntimeError, "Invalid command: #{s[0]}"
190
+ end
191
+ s.shift
192
+
193
+ info = ParticleCMD::Info.new(s)
194
+ e[:bot].handle_command(e, info || '')
195
+ end
196
+ end
197
+
198
+ #
199
+ # Groups
200
+ #
201
+
202
+ group('world').tap do |g|
203
+ g.perm @name, 'help', 'root'
204
+ g.perm @name, 'help', 'what'
205
+
206
+ g.perm @name, 'key', 'generate'
207
+ g.perm @name, 'key', 'use'
208
+ end
209
+
210
+ group('admin').tap do |g|
211
+ g.perm @name, 'reload', 'root'
212
+
213
+ g.perm @name, 'groups', 'root'
214
+ g.perm @name, 'groups-add', 'root'
215
+ g.perm @name, 'groups-del', 'root'
216
+
217
+ g.perm @name, 'flushq', 'root'
218
+ g.perm @name, 'flushq', 'targeted'
219
+ end
220
+
221
+ #
222
+ # help command
223
+ #
224
+
225
+ help_cmd = cmd 'help'
226
+
227
+ help_cmd.branch('root', '') do |e, c|
228
+ e.nreply "Open #{@webaddr}/ to get info about all available commands and groups"
229
+ end.description = 'Sends a link to the index help page'
230
+
231
+ help_cmd.branch('what', 'what') do |e, c|
232
+ arr = c.positionals['what'].split('/', 3)
233
+ if arr.length == 1
234
+ ok = false
235
+ e[:bot].plugins.each_pair do |pname, plugin|
236
+ plugin.commands.each do |cname, command|
237
+ if cname == arr[0]
238
+ e.nreply "Help for #{pname}/#{cname}: #{@webaddr}/#{pname}##{cname}"
239
+ ok = true
240
+ break
241
+ end
242
+ end
243
+ break if ok
244
+ end
245
+ e.nreply "Cannot find such command" unless ok
246
+ elsif arr.length == 2
247
+ unless e[:bot].plugins.include? arr[0]
248
+ e.nreply "Cannot find such plugin"
249
+ next
250
+ end
251
+ pname = arr[0]
252
+ plugin = e[:bot].plugins[pname]
253
+ cname = arr[1]
254
+ if plugin.commands.include? cname
255
+ e.nreply "Help for #{pname}/#{cname}: #{@webaddr}/#{pname}##{cname}"
256
+ else
257
+ e.nreply "Cannot find such command"
258
+ end
259
+ elsif arr.length == 3
260
+ pname = arr[0]
261
+ plugin = e[:bot].plugins[pname]
262
+ cname = arr[1]
263
+ unless plugin.commands.include? cname
264
+ e.nreply "Cannot find such command"
265
+ next
266
+ end
267
+ command = plugin.commands[cname]
268
+ bname = arr[2]
269
+ unless command.branches.include? bname
270
+ e.nreply "Cannot find such command branch"
271
+ next
272
+ end
273
+ e.nreply "Help for #{pname}/#{cname}/#{bname}: #{@webaddr}/#{pname}##{cname}/#{bname}"
274
+ end
275
+ end.tap do |b|
276
+ b.description = 'Sends a link to the help page for the given command'
277
+ b.definition.tap do |d|
278
+ d.description :positional, 'what', <<~HELP
279
+ Command name. Can be in these forms:
280
+ - command
281
+ - plugin/command
282
+ - plugin/command/branch
283
+ HELP
284
+ end
285
+ end
286
+
287
+ #
288
+ # key command
289
+ #
290
+
291
+ key_cmd = cmd 'key'
292
+
293
+ key_cmd.branch('generate', '') do |e, c|
294
+ @key = Random.rand(44**44..55**55).to_s(36)
295
+ @log.important @key
296
+ e.nreply 'Done!'
297
+ end.tap do |b|
298
+ b.description = 'Generates an unique key and prints it to the console'
299
+ end
300
+
301
+ key_cmd.branch('use', 'key') do |e, c|
302
+ if c.positionals['key'] == @key
303
+ e[:socket].set_group e[:host], 'admin'
304
+ e.nreply 'Done!'
305
+ else
306
+ e.nreply 'Invalid!'
307
+ end
308
+ @key = nil
309
+ end.tap do |b|
310
+ b.description = 'Gives you admin status if the given key is correct'
311
+ b.definition.tap do |d|
312
+ d.description :positional, 'key', 'The generated key'
313
+ end
314
+ end
315
+
316
+ #
317
+ # reload command
318
+ #
319
+
320
+ reload_cmd = cmd 'reload'
321
+
322
+ reload_cmd.branch('root', '-all -plugins! -plugins -groups -cooldowns') do |e, c|
323
+ all = c.flags['all']
324
+ plugins = c.flags['plugins!'] || all
325
+ plugin_config = c.flags['plugins'] || plugins || all
326
+ groups = c.flags['groups'] || plugins || all
327
+ cooldowns = c.flags['cooldowns'] || plugins || all
328
+
329
+ bot = e[:bot]
330
+ bot.config_mtx.synchronize do
331
+ bot.config = YAML.parse_file(bot.config_file).to_ruby
332
+
333
+ bot.prefix = bot.config['prefix'] || '!'
334
+ bot.log.info "prefix = #{bot.prefix}"
335
+
336
+ bot.plugins = {} if plugins
337
+
338
+ bot.load_plugin_config if plugin_config
339
+ bot.load_group_config if groups
340
+ bot.load_cooldown_config if cooldowns
341
+ end
342
+
343
+ e.nreply 'Done!'
344
+ end.tap do |b|
345
+ b.description = 'Reloads given parts of the bot'
346
+ b.definition.tap do |d|
347
+ d.description :flag, 'all', 'Relaod everything'
348
+ d.description :flag, 'plugins!', 'Reload plugins (also reloads plugin configs, groups and cooldowns)'
349
+ d.description :flag, 'plugins', 'Reload plugin configs'
350
+ d.description :flag, 'groups', 'Reload groups'
351
+ d.description :flag, 'cooldowns', 'Reload cooldowns'
352
+ end
353
+ end
354
+
355
+ #
356
+ # groups command
357
+ #
358
+
359
+ groups_cmd = cmd 'groups'
360
+
361
+ groups_cmd.branch('root', 'who') do |e, c|
362
+ s = e[:socket]
363
+ who = e[:socket].usercache.get(c.positionals['who'], false)[:host] || c.positionals['who']
364
+ e.nreply "#{who}'s groups: #{s.list_groups(who).join(', ')}"
365
+ end.tap do |b|
366
+ b.description = 'Lists groups of the given user'
367
+ b.definition.description :positional, 'who', 'Command target'
368
+ end
369
+
370
+ #
371
+ # groups-add command
372
+ #
373
+
374
+ groups_add_cmd = cmd 'groups-add'
375
+
376
+ groups_add_cmd.branch('root', 'who ...') do |e, c|
377
+ s = e[:socket]
378
+ who = s.usercache.get(c.positionals['who'], false)[:host] || c.positionals['who']
379
+ c.extra.each do |gname|
380
+ s.set_group(who, gname)
381
+ end
382
+ e.nreply 'Done!'
383
+ end.tap do |b|
384
+ b.description = 'Gives user given groups'
385
+ b.definition.description :positional, 'who', 'Command target'
386
+ end
387
+
388
+ #
389
+ # groups-del command
390
+ #
391
+
392
+ groups_del_cmd = cmd 'groups-del'
393
+
394
+ groups_del_cmd.branch('root', 'who ...') do |e, c|
395
+ s = e[:socket]
396
+ who = s.usercache.get(c.positionals['who'], false)[:host] || c.positionals['who']
397
+ c.extra.each do |gname|
398
+ s.del_group(who, gname)
399
+ end
400
+ e.nreply 'Done!'
401
+ end.tap do |b|
402
+ b.description = 'Removes given groups from the user'
403
+ b.definition.description :positional, 'who', 'Command target'
404
+ end
405
+
406
+ #
407
+ # flushq command
408
+ #
409
+
410
+ flushq_cmd = cmd 'flushq'
411
+
412
+ flushq_cmd.branch('root', '') do |e, c|
413
+ e[:socket].queue.clear
414
+ e.nreply 'Done!'
415
+ end.tap do |b|
416
+ b.description = 'Flushes the queue of the current server'
417
+ end
418
+
419
+ flushq_cmd.branch('targeted', 'server') do |e, c|
420
+ bot = e[:bot]
421
+ if bot.sockets.include? c.positionals['server']
422
+ bot.sockets[c.positionals['server']].queue.clear
423
+ e.nreply 'Done!'
424
+ else
425
+ e.nreply 'There\'s no such server!'
426
+ end
427
+ end.tap do |b|
428
+ b.description = 'Flushes the queue of the given server'
429
+ end
@@ -0,0 +1,24 @@
1
+ class TinyIRC::UserCache
2
+ def initialize
3
+ @cache = {}
4
+ end
5
+
6
+ def get(nick, add = true)
7
+ if add
8
+ @cache[nick] ||= { nick: nick }
9
+ @cache[nick]
10
+ else
11
+ @cache[nick] || { nick: nick }
12
+ end
13
+ end
14
+
15
+ def set(entry)
16
+ @cache[entry[:nick]] = entry
17
+ end
18
+
19
+ def rename(o, n)
20
+ @cache[n] = @cache.delete(o) if @cache[o]
21
+ @cache[n][:nick] = n
22
+ @cache[n]
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module TinyIRC
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ <div class="app-text">
2
+ <h4 class="app-title">404</h4>
3
+ Sorry, but there's no such page here
4
+ </div>
@@ -0,0 +1,5 @@
1
+ <div class="app-text">
2
+ <% @err = env['sinatra.error'] %>
3
+ <h4 class="app-title"><%= h @err.class.name + ' - ' + @err.message %></h4>
4
+ <p class="app-title">If you are hosting this bot, check logs at <%= DateTime.now.strftime %></p>
5
+ </div>
@@ -0,0 +1,31 @@
1
+ <div class="app-text">
2
+ <h4 class="app-title">Info</h4>
3
+ Use sidebar to get help for plugin commands
4
+ </div>
5
+
6
+ <div class="app-text">
7
+ <h4 class="app-title">Groups</h4>
8
+ <!--<%= ERB::Util::html_escape TinyIRC::App.bot.inspect %>-->
9
+ <% TinyIRC::App.bot.groups.each_pair do |gname, group| %>
10
+ <h6 class="group-name"><%= h gname %></h6>
11
+ <ul class="group-list">
12
+ <% group.perms.each do |perm| %>
13
+ <li class="group-list-item">
14
+ <span class=""><%= h perm.to_s %></span>
15
+ </li>
16
+ <% end %>
17
+ </ul>
18
+ <% end %>
19
+ <% TinyIRC::App.bot.plugins.each_pair do |pname, plugin| %>
20
+ <% plugin.groups.each_pair do |gname, group| %>
21
+ <h6 class="group-name"><%= h gname %></h6>
22
+ <ul class="group-list">
23
+ <% group.perms.each do |perm| %>
24
+ <li class="group-list-item">
25
+ <span class=""><%= h perm.to_s %></span>
26
+ </li>
27
+ <% end %>
28
+ </ul>
29
+ <% end %>
30
+ <% end %>
31
+ </div>
@@ -0,0 +1,62 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>TinyIRC Help</title>
5
+
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+
8
+ <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
9
+ <link rel="stylesheet" href="https://code.getmdl.io/1.3.0/material.blue_grey-blue.min.css">
10
+ <script defer src="https://code.getmdl.io/1.3.0/material.min.js"></script>
11
+
12
+ <style>
13
+ .app-text {
14
+ max-width: 50%;
15
+ margin: 8px;
16
+ margin-left: auto;
17
+ margin-right: auto;
18
+ }
19
+
20
+ .app-text .app-title {
21
+ text-align: center;
22
+ }
23
+
24
+ .group-name {
25
+ margin-bottom: 0;
26
+ }
27
+
28
+ .group-list {
29
+ margin-top: 0;
30
+ }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <div class="mdl-layout mdl-js-layout">
35
+ <header class="mdl-layout__header mdl-layout__header--scroll">
36
+ <div class="mdl-layout__header-row">
37
+ <span class="mdl-layout-title">TinyIRC Help</span>
38
+ <nav class="mdl-navigation">
39
+ <a class="mdl-navigation__link" href="/">Home</a>
40
+ </nav>
41
+ <div class="mdl-layout-spacer"></div>
42
+ <span>Page: <%= h @pagename %></span>
43
+ </div>
44
+ </header>
45
+ <div class="mdl-layout__drawer">
46
+ <span class="mdl-layout-title">TinyIRC Help</span>
47
+ <span style="margin-left: 40px">Page: <%= h @pagename %></span>
48
+ <nav class="mdl-navigation">
49
+ <a class="mdl-navigation__link" href="/">Home</a>
50
+ <% TinyIRC::App.bot.plugins.each_pair do |pname, plugin| %>
51
+ <a class="mdl-navigation__link" href="/<%= pname %>"><%= pname %></a>
52
+ <% end %>
53
+ </nav>
54
+ </div>
55
+ <main class="mdl-layout__content">
56
+ <div class="page-content">
57
+ <%= yield %>
58
+ </div>
59
+ </main>
60
+ </div>
61
+ </body>
62
+ </html>
@@ -0,0 +1,42 @@
1
+ <div class="app-text">
2
+ <h3 class="app-title"><%= @pagename %></h3>
3
+ </div>
4
+
5
+ <div class="app-text">
6
+ <h4 class="app-title">Groups</h4>
7
+ <% @plugin.groups.each_pair do |gname, group| %>
8
+ <h6 class="group-name"><%= h gname %></h6>
9
+ <ul class="group-list">
10
+ <% group.perms.each do |perm| %>
11
+ <li class="group-list-item">
12
+ <span class=""><%= h perm.to_s %></span>
13
+ </li>
14
+ <% end %>
15
+ </ul>
16
+ <% end %>
17
+ </div>
18
+
19
+ <div class="app-text">
20
+ <h4 class="app-title">Commands</h4>
21
+ </div>
22
+
23
+ <% @plugin.commands.each_pair do |cname, command| %>
24
+ <div class="app-text">
25
+ <h5 class="app-title" id="<%= h(cname) %>"><%= h(cname) %></h5>
26
+ </div>
27
+ <% command.branches.each do |bname, branch| %>
28
+ <div class="app-text">
29
+ <% @sig = h branch.definition.command_signature %>
30
+ <h6 id="<%= h(cname) + '/' + h(bname) %>"><%= @sig %></h6>
31
+ <div class="mdl-tooltip mdl-tooltip--left" for="<%= h(cname) + '/' + h(bname) %>">
32
+ <%= @plugin.name + '/' + cname + '/' + bname %>
33
+ </div>
34
+ <% if branch.cooldown != 0 %>
35
+ <pre>Cooldown: <%= h branch.cooldown %></pre>
36
+ <% end %>
37
+ <pre><%= h branch.description %></pre>
38
+ <pre><%= h branch.definition.command_description %></pre>
39
+ <hr/>
40
+ </div>
41
+ <% end %>
42
+ <% end %>
data/lib/tinyirc.rb ADDED
@@ -0,0 +1,26 @@
1
+ require 'set'
2
+ require 'shellwords'
3
+ require 'socket'
4
+ require 'yaml'
5
+
6
+ require 'particlecmd'
7
+ require 'particlelog'
8
+
9
+ require 'thin'
10
+ require 'sinatra/base'
11
+
12
+ require 'sqlite3'
13
+
14
+ require 'tinyirc/version'
15
+
16
+ module TinyIRC
17
+ end
18
+
19
+ require 'tinyirc/app'
20
+ require 'tinyirc/bot'
21
+ require 'tinyirc/command'
22
+ require 'tinyirc/event'
23
+ require 'tinyirc/ircsocket'
24
+ require 'tinyirc/perms'
25
+ require 'tinyirc/plugin'
26
+ require 'tinyirc/usercache'
data/tinyirc.gemspec ADDED
@@ -0,0 +1,32 @@
1
+
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'tinyirc/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'tinyirc'
8
+ spec.version = TinyIRC::VERSION
9
+ spec.authors = ['Nickolay Ilyushin']
10
+ spec.email = ['nickolay02@inbox.ru']
11
+
12
+ spec.summary = 'A modular IRC bot framework'
13
+ spec.description = 'A modular IRC bot framework'
14
+ spec.homepage = 'https://github.com/handicraftsman/tinyirc'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = 'exe'
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ['lib']
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.16'
25
+ spec.add_development_dependency 'rake', '~> 10.0'
26
+
27
+ spec.add_dependency 'particlecmd', '~> 0.1'
28
+ spec.add_dependency 'particlelog', '~> 0.1'
29
+ spec.add_dependency 'sinatra', '~> 2.0'
30
+ spec.add_dependency 'thin', '~> 1.2'
31
+ spec.add_dependency 'sqlite3', '~> 1.3'
32
+ end