bitbot 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ *.log
2
+ *.output
3
+ .bundle
4
+ .gems
5
+ *.pid
6
+ bitbot.db
7
+ pkg/
8
+ Gemfile.lock
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :rubygems
2
+
3
+ gemspec
@@ -0,0 +1,139 @@
1
+ # Bitbot
2
+
3
+ An IRC bit-tip style bot. Just for fun, don't use with lots of coins.
4
+
5
+ ## Security
6
+
7
+ **You probably don't want to use this.**
8
+
9
+ This section is first because anything Bitcoin related seems to attract
10
+ l33t hax0rs. This is just a toy. You probably shouldn't use it, and if
11
+ you do, you shouldn't put much money into it. Here's why:
12
+
13
+ Bitbot does not operate on nicknames, but usernames. Therefore, it's
14
+ only secure on IRC networks where usernames are forced. *This probably
15
+ isn't true of whatever IRC network you're on.* (On our IRC network,
16
+ usernames cannot be spoofed.) In order to be secure, the bot would need
17
+ to authenticate users in some other way, perhaps by using NickServ or
18
+ something. Pull requests accepted.
19
+
20
+ Bitbot uses an online wallet at https://blockchain.info. This was
21
+ because it was the easiest path forward, but it is not the most secure.
22
+ Blockchain.info itself seems reasonably secure, but if someone were to
23
+ breach its servers, they would be able to steal all the coins stored by
24
+ Bitbot. A better setup would be for Bitbot to perform its own
25
+ transactions on the Bitcoin network. Pull requests accepted. :)
26
+
27
+ Bitbot is based on a Ruby IRC library called
28
+ [Cinch](https://github.com/cinchrb/cinch). I have no idea how secure
29
+ Cinch is - it's possible that it has a remote code execution
30
+ vulnerability. If it does, an attacker can steal all the coins from
31
+ Bitbot.
32
+
33
+ Hopefully the above has scared you off from installing this and using it
34
+ on Freenode. If it didn't, then you deserve to lose whatever you put in.
35
+ You could just send it to me directly instead:
36
+ `1zachsQ82fM7DC4HZQXMAAmkZ2Qg7pH2V`.
37
+
38
+ ## How Does It Work
39
+
40
+ Bitbot lets people in IRC send each other Bitcoin. It's loosely modelled
41
+ after the [Reddit Bitcointip](http://redd.it/13iykn).
42
+
43
+ The big difference is that in Bitbot, tips between users do not show up
44
+ in the blockchain. Bitbot maintains a wallet with all of the deposited
45
+ funds, and then keeps a record of transactions between users. Only
46
+ deposits and withdrawals to and from Bitbot show up in the Bitcoin
47
+ blockchain.
48
+
49
+ We do this because our tips are generally pretty tiny, and if each one
50
+ were a real transaction, our entire "Bitconomy" would be eaten up by
51
+ transaction fees.
52
+
53
+ ## Installation
54
+
55
+ * Install the dependencies (sqlite3)
56
+
57
+ * Install the gem:
58
+
59
+ ```bash
60
+ gem install bitbot
61
+ ```
62
+
63
+ * Create an online wallet at https://blockchain.info/wallet/ . Create a
64
+ secondary password on it, and I suggest locking access to the IP from
65
+ which your bot will be running for an little extra security. It's
66
+ probably also good to set up (encrypted) wallet backups to go to your
67
+ email address.
68
+
69
+ * Create a data directory for Bitbot:
70
+
71
+ ```bash
72
+ $ mkdir ~/.bitbot
73
+ ```
74
+
75
+ * Create a bitbot.yml file that looks like this:
76
+
77
+ ```yaml
78
+ irc:
79
+ server: irc.example.com
80
+ port: 8765
81
+ ssl: true
82
+ nick: bitbot
83
+ username: bitbot@example.com
84
+ password: blahblah
85
+
86
+ blockchain:
87
+ wallet_id: <long guid>
88
+ password1: <password>
89
+ password2: <secondary password>
90
+
91
+ data:
92
+ path: /home/me/.bitbot
93
+ ```
94
+
95
+ * Start the bot:
96
+
97
+ ```bash
98
+ $ bitbot start <path to config.yml>
99
+ ```
100
+
101
+ ## Usage
102
+
103
+ ### Help
104
+
105
+ To get help, `/msg bitbot help`. He will respond with a list of commands
106
+ he supports:
107
+
108
+ ```
109
+ bitbot: Commands: deposit, balance, history, withdrawal
110
+ ```
111
+
112
+ ### Deposit
113
+
114
+ To make a deposit to your Bitbot account, ask bitbot for your depositing
115
+ address: `/msg bitbot deposit`. Bitbot will respond with a Bitcoin
116
+ address to send coins to. Once
117
+
118
+ ### Tipping
119
+
120
+ To tip somebody, you must both be in a room with Bitbot. The syntax is
121
+ `+tip <nickname> <amount in BTC> <message>`. When Bitbot sees that, it
122
+ verifies that you have the funds to tip, does a `whois` on the recipient
123
+ nickname, and then transfers the funds. He then responds with a
124
+ confirmation:
125
+
126
+ ```
127
+ bob: how do i make rails tests fast?
128
+ john: refactor your app so you can test it without rails
129
+ bob: +tip john 0.01 ur so smart
130
+ bitbot: [✔] Verified: bob ➜ ฿+0.01 [$1.12] ➜ john
131
+ ```
132
+
133
+ ### Withdrawing
134
+
135
+ When you want to withdraw funds to another Bitcoin address, just tell
136
+ Bitbot: `/msg bitbot withdraw <amount> <address>`.
137
+
138
+ Bitbot will verify that you have enough money, and send `<amount>` BTC -
139
+ 0.0005 (to cover the transaction fee) to the specified `<address>`.
@@ -0,0 +1,3 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'daemons'
4
+ require 'yaml'
5
+
6
+ require 'bitbot/bot'
7
+ require 'bitbot/database'
8
+
9
+ def usage
10
+ puts "Usage: #{File.basename($0)} {start|stop|run} <path to config.yml>"
11
+ exit 1
12
+ end
13
+
14
+ if ARGV.length < 2
15
+ usage()
16
+ end
17
+
18
+ config_file = File.expand_path(ARGV.pop)
19
+
20
+ unless File.exist?(config_file)
21
+ usage()
22
+ end
23
+
24
+ config = YAML::load(File.open(config_file))
25
+ unless File.exist?(File.dirname(config['data']['path']))
26
+ puts "Data directory does not exist: #{File.dirname(config['data']['path'])}"
27
+ exit 1
28
+ end
29
+
30
+ # Update the database file
31
+ Bitbot::Database.new(File.join(config['data']['path'], "bitbot.db")).upgrade_schema()
32
+
33
+ Daemons.run_proc('bitbot',
34
+ :hard_exit => true,
35
+ :backtrace => true,
36
+ :ontop => false,
37
+ :multiple => false,
38
+ :monitor => true,
39
+ :log_output => true) do
40
+
41
+ bot = Bitbot::Bot.new(config)
42
+
43
+ Thread.new do
44
+ loop do
45
+ begin
46
+ bot.update_exchange_rates()
47
+ bot.update_addresses()
48
+ rescue => e
49
+ puts "exception in loop addresses: #{e}"
50
+ puts e.backtrace
51
+ ensure
52
+ sleep 60
53
+ end
54
+ end
55
+ end
56
+
57
+ bot.start
58
+ end
59
+
@@ -0,0 +1,27 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{bitbot}
5
+ s.version = "0.0.1"
6
+ s.platform = Gem::Platform::RUBY
7
+ s.authors = ["Zach Wily"]
8
+ s.email = ["zach@zwily.com"]
9
+ s.homepage = %q{http://github.com/zwily/bitbot}
10
+ s.summary = %q{Bitcoin IRC Tip Bot}
11
+
12
+ s.files = `git ls-files`.split("\n")
13
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
15
+ s.default_executable = %q{bitbot}
16
+ s.require_paths = ["lib"]
17
+
18
+
19
+ s.add_dependency "cinch"
20
+ s.add_dependency "daemons"
21
+ s.add_dependency "sqlite3"
22
+ s.add_dependency "httparty"
23
+
24
+ s.add_development_dependency "rspec", "~> 2.5"
25
+ s.add_development_dependency "yard"
26
+ end
27
+
@@ -0,0 +1,70 @@
1
+ require 'httparty'
2
+ require 'cgi'
3
+ require 'json'
4
+
5
+ module Bitbot
6
+ class Blockchain
7
+ def initialize(id, password1, password2)
8
+ @id = id
9
+ @password1 = password1
10
+ @password2 = password2
11
+ end
12
+
13
+ def request(api, action = nil, params = {})
14
+ path = if api == :merchant
15
+ "merchant/#{@id}/#{action}"
16
+ elsif api == :ticker
17
+ "ticker"
18
+ else
19
+ "#{api}/#{action}"
20
+ end
21
+ url = "https://blockchain.info/#{path}?"
22
+ params.each do |key, value|
23
+ url += "#{key}=#{CGI::escape value.to_s}&"
24
+ end
25
+
26
+ response = HTTParty.get(url)
27
+ raise "HTTP Error: #{response}" unless response.code == 200
28
+
29
+ JSON.parse(response.body)
30
+ end
31
+
32
+ def create_deposit_address_for_user_id(user_id)
33
+ self.request(:merchant, :new_address,
34
+ :password => @password1,
35
+ :second_password => @password2,
36
+ :label => user_id)
37
+ end
38
+
39
+ def get_details_for_address(address)
40
+ self.request(:address, address, :format => :json)
41
+ end
42
+
43
+ def get_addresses_in_wallet
44
+ response = self.request(:merchant, :list, :password => @password1)
45
+ response["addresses"]
46
+ end
47
+
48
+ def get_balance_for_address(address, confirmations = 1)
49
+ response = self.request(:merchant, :address_balance,
50
+ :password => @password1,
51
+ :address => address,
52
+ :confirmations => confirmations)
53
+ response["balance"]
54
+ end
55
+
56
+ def create_payment(address, amount, fee)
57
+ response = self.request(:merchant, :payment,
58
+ :password => @password1,
59
+ :second_password => @password2,
60
+ :to => address,
61
+ :amount => amount,
62
+ :fee => fee)
63
+ response
64
+ end
65
+
66
+ def get_exchange_rates
67
+ self.request(:ticker)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,404 @@
1
+ # coding: utf-8
2
+
3
+ require 'cinch'
4
+ require 'bitbot/database'
5
+ require 'bitbot/blockchain'
6
+ require 'ircstring'
7
+
8
+ module Bitbot
9
+ class Bot < Cinch::Bot
10
+ def initialize(config = {})
11
+ @bot_config = config
12
+ @cached_addresses = nil
13
+ @cached_exchange_rates = nil
14
+
15
+ super() do
16
+ configure do |c|
17
+ c.server = config['irc']['server']
18
+ c.port = config['irc']['port']
19
+ c.ssl.use = config['irc']['ssl']
20
+ c.channels = config['irc']['channels']
21
+ c.nick = config['irc']['nick'] || 'bitbot'
22
+ c.user = config['irc']['username'] || 'bitbot'
23
+ c.password = config['irc']['password']
24
+ c.verbose = config['irc']['verbose']
25
+ end
26
+
27
+ on :private, /^help$/ do |m|
28
+ self.bot.command_help(m)
29
+ end
30
+
31
+ on :private, /^balance/ do |m|
32
+ self.bot.command_balance(m)
33
+ end
34
+
35
+ on :private, /^history/ do |m|
36
+ self.bot.command_history(m)
37
+ end
38
+
39
+ on :private, /^withdraw$/ do |m|
40
+ m.reply "Usage: withdraw <amount in BTC> <address>"
41
+ end
42
+
43
+ on :private, /^withdraw\s+([\d.]+)\s+([13][0-9a-zA-Z]{26,35})/ do |m, amount, address|
44
+ self.bot.command_withdraw(m, amount, address)
45
+ end
46
+
47
+ on :private, /^deposit/ do |m|
48
+ self.bot.command_deposit(m)
49
+ end
50
+
51
+ on :channel, /^\+tipstats$/ do |m|
52
+ self.bot.command_tipstats(m)
53
+ end
54
+
55
+ on :channel, /^\+tip\s+(\w+)\s+([\d.]+)\s+?(.*)/ do |m, recipient, amount, message|
56
+ self.bot.command_tip(m, recipient, amount, message)
57
+ end
58
+ end
59
+ end
60
+
61
+ def get_db
62
+ Bitbot::Database.new(File.join(@bot_config['data']['path'], "bitbot.db"))
63
+ end
64
+
65
+ def get_blockchain
66
+ Bitbot::Blockchain.new(@bot_config['blockchain']['wallet_id'],
67
+ @bot_config['blockchain']['password1'],
68
+ @bot_config['blockchain']['password2'])
69
+ end
70
+
71
+ def satoshi_to_str(satoshi)
72
+ str = "฿%.8f" % (satoshi.to_f / 10**8)
73
+ # strip trailing 0s
74
+ str.gsub(/0*$/, '')
75
+ end
76
+
77
+ def satoshi_to_usd(satoshi)
78
+ if @cached_exchange_rates && @cached_exchange_rates["USD"]
79
+ "$%.2f" % (satoshi.to_f / 10**8 * @cached_exchange_rates["USD"]["15m"])
80
+ else
81
+ "$?"
82
+ end
83
+ end
84
+
85
+ def satoshi_with_usd(satoshi)
86
+ btc_str = satoshi_to_str(satoshi)
87
+ if satoshi < 0
88
+ btc_str = btc_str.irc(:red)
89
+ else
90
+ btc_str = btc_str.irc(:green)
91
+ end
92
+
93
+ usd_str = "[".irc(:grey) + satoshi_to_usd(satoshi).irc(:blue) + "]".irc(:grey)
94
+
95
+ "#{btc_str} #{usd_str}"
96
+ end
97
+
98
+ # This should be called periodically to keep exchange rates up to
99
+ # date.
100
+ def update_exchange_rates
101
+ @cached_exchange_rates = get_blockchain().get_exchange_rates()
102
+ end
103
+
104
+ # This method needs to be called periodically, like every minute in
105
+ # order to process new transactions.
106
+ def update_addresses
107
+ cache_file = File.join(@bot_config['data']['path'], "cached_addresses.yml")
108
+ if @cached_addresses.nil?
109
+ # Load from the cache, if available, on first load
110
+ @cached_addresses = YAML.load(File.read(cache_file)) rescue nil
111
+ end
112
+
113
+ blockchain = get_blockchain()
114
+
115
+ # Updates the cached map of depositing addresses.
116
+ new_addresses = {}
117
+ all_addresses = []
118
+
119
+ addresses = blockchain.get_addresses_in_wallet()
120
+ addresses.each do |address|
121
+ all_addresses << address["address"]
122
+ next unless address["label"] =~ /^\d+$/
123
+
124
+ user_id = address["label"].to_i
125
+
126
+ new_addresses[user_id] = address
127
+
128
+ # We set a flag on the address saying we need to get the
129
+ # confirmed balance IF the previous entry has the flag, OR
130
+ # the address is new OR if the balance does not equal the
131
+ # previous balance. We only clear the field when the balance
132
+ # equals the confirmed balance.
133
+ address["need_confirmed_balance"] = @cached_addresses[user_id]["need_confirmed_balance"] rescue true
134
+ if address["balance"] != (@cached_addresses[user_id]["balance"] rescue nil)
135
+ address["need_confirmed_balance"] = true
136
+ end
137
+ end
138
+
139
+ # Now go through new addresses, performing any confirmation checks
140
+ # for flagged ones.
141
+ new_addresses.each do |user_id, address|
142
+ if address["need_confirmed_balance"]
143
+ balance = blockchain.get_balance_for_address(address["address"])
144
+ address["confirmed_balance"] = balance
145
+
146
+ if address["confirmed_balance"] == address["balance"]
147
+ address["need_confirmed_balance"] = false
148
+ end
149
+
150
+ # Process any transactions for this address
151
+ self.process_new_transactions_for_address(address, user_id, all_addresses)
152
+ end
153
+ end
154
+
155
+ # Thread-safe? Sure, why not.
156
+ @cached_addresses = new_addresses
157
+
158
+ # Cache them on disk for faster startups
159
+ File.write(cache_file, YAML.dump(@cached_addresses))
160
+ end
161
+
162
+ def process_new_transactions_for_address(address, user_id, all_addresses)
163
+ db = get_db()
164
+ blockchain = get_blockchain()
165
+
166
+ existing_transactions = {}
167
+ db.get_incoming_transaction_ids().each do |txid|
168
+ existing_transactions[txid] = true
169
+ end
170
+
171
+ response = blockchain.get_details_for_address(address["address"])
172
+
173
+ username = db.get_username_for_user_id(user_id)
174
+ ircuser = self.user_with_username(username)
175
+
176
+ response["txs"].each do |tx|
177
+ # Skip ones we already have in the database
178
+ next if existing_transactions[tx["hash"]]
179
+
180
+ # Skip any transactions that have an existing bitbot address
181
+ # as an input
182
+ if tx["inputs"].any? {|input| all_addresses.include? input["prev_out"]["addr"] }
183
+ debug "Skipping tx with bitbot input address: #{tx["hash"]}"
184
+ next
185
+ end
186
+
187
+ # find the total amount for this address
188
+ amount = 0
189
+ tx["out"].each do |out|
190
+ if out["addr"] == address["address"]
191
+ amount += out["value"]
192
+ end
193
+ end
194
+
195
+ # Skip unless it's in a block (>=1 confirmation)
196
+ if !tx["block_height"] || tx["block_height"] == 0
197
+ ircuser.msg "Waiting for confirmation of transaction of " +
198
+ satoshi_with_usd(amount) +
199
+ " in transaction #{tx["hash"].irc(:grey)}"
200
+ next
201
+ end
202
+
203
+ # There is a unique constraint on incoming_transaction, so this
204
+ # will fail if for some reason we try to add it again.
205
+ if db.create_transaction_from_deposit(user_id, amount, tx["hash"])
206
+ # Notify the depositor
207
+ if ircuser
208
+ ircuser.msg "Received deposit of " +
209
+ satoshi_with_usd(amount) + ". Current balance is " +
210
+ satoshi_with_usd(db.get_balance_for_user_id(user_id)) + "."
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ def user_with_username(username)
217
+ self.bot.user_list.each do |user|
218
+ return user if user.user == username
219
+ end
220
+ end
221
+
222
+ def command_help(msg)
223
+ msg.reply "Commands: balance, history, withdraw, deposit, +tip, +tipstats"
224
+ end
225
+
226
+ def command_balance(msg)
227
+ db = get_db()
228
+ user_id = db.get_or_create_user_id_for_username(msg.user.user)
229
+
230
+ msg.reply "Balance is #{satoshi_with_usd(db.get_balance_for_user_id(user_id))}"
231
+ end
232
+
233
+ def command_deposit(msg, create = true)
234
+ db = get_db()
235
+ user_id = db.get_or_create_user_id_for_username(msg.user.user)
236
+
237
+ unless @cached_addresses
238
+ msg.reply "Bitbot is not initialized yet. Please try again later."
239
+ return
240
+ end
241
+
242
+ if address = @cached_addresses[user_id]
243
+ msg.reply "Send deposits to #{address["address"].irc(:bold)}. " +
244
+ "This address is specific to you, and any funds delivered " +
245
+ "to it will be added to your account after confirmation."
246
+ return
247
+ end
248
+
249
+ unless create
250
+ msg.reply "There was a problem getting your deposit address. " +
251
+ "Please contact your friends Bitbot admin."
252
+ return
253
+ end
254
+
255
+ # Attempt to create an address.
256
+ blockchain = get_blockchain()
257
+ blockchain.create_deposit_address_for_user_id(user_id)
258
+
259
+ # Force a refresh of the cached address list...
260
+ self.update_addresses()
261
+
262
+ self.command_deposit(msg, false)
263
+ end
264
+
265
+ def command_history(msg)
266
+ db = get_db()
267
+ user_id = db.get_or_create_user_id_for_username(msg.user.user)
268
+
269
+ command_balance(msg)
270
+
271
+ n = 0
272
+ db.get_transactions_for_user_id(user_id).each do |tx|
273
+ time = Time.at(tx[:created_at].to_i).strftime("%Y-%m-%d")
274
+ amount = satoshi_with_usd(tx[:amount])
275
+ action = if tx[:amount] < 0 && tx[:other_user_id]
276
+ "to #{tx[:other_username]}"
277
+ elsif tx[:amount] > 0 && tx[:other_user_id]
278
+ "from #{tx[:other_username]}"
279
+ elsif tx[:withdrawal_address]
280
+ "withdrawal to #{tx[:withdrawal_address]}"
281
+ elsif tx[:incoming_transaction]
282
+ "deposit from tx #{tx[:incoming_transaction]}"
283
+ end
284
+
285
+ msg.reply "#{time.irc(:grey)}: #{amount} #{action} #{"(#{tx[:note]})".irc(:grey) if tx[:note]}"
286
+
287
+ n += 1
288
+ break if n >= 10
289
+ end
290
+ end
291
+
292
+ def command_withdraw(msg, amount, address)
293
+ db = get_db()
294
+ user_id = db.get_or_create_user_id_for_username(msg.user.user)
295
+
296
+ satoshi = (amount.to_f * 10**8).to_i
297
+
298
+ # Perform the local transaction in the database. Note that we
299
+ # don't do the blockchain update in the transaction, because we
300
+ # don't want to roll back the transaction if the blockchain update
301
+ # *appears* to fail. It might look like it failed, but really
302
+ # succeed, letting someone withdraw money twice.
303
+ # TODO: don't hardcode fee
304
+ begin
305
+ db.create_transaction_from_withdrawal(user_id, satoshi, 500000, address)
306
+ rescue InsufficientFundsError
307
+ msg.reply "You don't have enough to withdraw #{satoshi_to_str(satoshi)} + 0.0005 fee"
308
+ return
309
+ end
310
+
311
+ blockchain = get_blockchain()
312
+ response = blockchain.create_payment(address, satoshi, 500000)
313
+ if response["tx_hash"]
314
+ msg.reply "Sent #{satoshi_with_usd(satoshi)} to #{address.irc(:bold)} " +
315
+ "in transaction #{response["tx_hash"].irc(:grey)}."
316
+ else
317
+ msg.reply "Something may have gone wrong with your withdrawal. Please contact " +
318
+ "your friendly Bitbot administrator to investigate where your money is."
319
+ end
320
+ end
321
+
322
+ def command_tipstats(msg)
323
+ db = get_db()
324
+ stats = db.get_tipping_stats
325
+
326
+ str = "Stats: ".irc(:grey) +
327
+ "tips today: " +
328
+ satoshi_with_usd(0 - stats[:total_tipped]) + " " +
329
+ "#{stats[:total_tips]} tips "
330
+
331
+ if stats[:tippers].length > 0
332
+ str += "biggest tipper: ".irc(:black) +
333
+ stats[:tippers][0][0].irc(:bold) +
334
+ " (#{satoshi_with_usd(0 - stats[:tippers][0][1])}) "
335
+ end
336
+
337
+ if stats[:tippees].length > 0
338
+ str += "biggest recipient: ".irc(:black) +
339
+ stats[:tippees][0][0].irc(:bold) +
340
+ " (#{satoshi_with_usd(stats[:tippees][0][1])}) "
341
+ end
342
+
343
+ msg.reply str
344
+ end
345
+
346
+ def command_tip(msg, recipient, amount, message)
347
+ db = get_db()
348
+
349
+ # Look up sender
350
+ user_id = db.get_or_create_user_id_for_username(msg.user.user)
351
+
352
+ # Look up recipient
353
+ recipient_ircuser = msg.channel.users.keys.find {|u| u.name == recipient }
354
+ unless recipient_ircuser
355
+ msg.user.msg("Could not find #{recipient} in the channel list.")
356
+ return
357
+ end
358
+ recipient_user_id = db.get_or_create_user_id_for_username(recipient_ircuser.user)
359
+
360
+ # Convert amount to satoshi
361
+ satoshi = (amount.to_f * 10**8).to_i
362
+ if satoshi <= 0
363
+ msg.user.msg("Cannot send a negative amount.")
364
+ return
365
+ end
366
+
367
+ # Attempt the transaction (will raise on InsufficientFunds)
368
+ begin
369
+ db.create_transaction_from_tip(user_id, recipient_user_id, satoshi, message)
370
+ rescue InsufficientFundsError
371
+ msg.reply "Insufficient funds! It's the thought that counts.", true
372
+ return
373
+ end
374
+
375
+ # Success! Let the room know...
376
+ msg.reply "[✔] Verified: ".irc(:grey).irc(:bold) +
377
+ msg.user.user.irc(:bold) +
378
+ " ➜ ".irc(:grey) +
379
+ satoshi_with_usd(satoshi) +
380
+ " ➜ ".irc(:grey) +
381
+ recipient_ircuser.user.irc(:bold)
382
+
383
+ # ... and let the sender know privately ...
384
+ msg.user.msg "You just sent " +
385
+ recipient_ircuser.user.irc(:bold) + " " +
386
+ satoshi_with_usd(satoshi) +
387
+ " in " +
388
+ msg.channel.name.irc(:bold) +
389
+ " bringing your balance to " +
390
+ satoshi_with_usd(db.get_balance_for_user_id(user_id)) +
391
+ "."
392
+
393
+ # ... and let the recipient know privately.
394
+ recipient_ircuser.msg msg.user.user.irc(:bold) +
395
+ " just sent you " +
396
+ satoshi_with_usd(satoshi) +
397
+ " in " +
398
+ msg.channel.name.irc(:bold) +
399
+ " bringing your balance to " +
400
+ satoshi_with_usd(db.get_balance_for_user_id(recipient_user_id)) +
401
+ ". Type 'help' to list bitbot commands."
402
+ end
403
+ end
404
+ end
@@ -0,0 +1,142 @@
1
+ require 'sqlite3'
2
+
3
+ module Bitbot
4
+ class InsufficientFundsError < StandardError; end
5
+
6
+ class Database
7
+ def initialize(path)
8
+ @db = SQLite3::Database.new path
9
+ @db.execute('PRAGMA foreign_keys = ON')
10
+ end
11
+
12
+ def upgrade_schema
13
+ @db.execute(<<-ENDSQL)
14
+ create table if not exists users (
15
+ id integer primary key autoincrement,
16
+ created_at integer not null,
17
+ username text not null unique
18
+ )
19
+ ENDSQL
20
+
21
+ @db.execute(<<-ENDSQL)
22
+ create table if not exists transactions (
23
+ id integer primary key autoincrement,
24
+ created_at integer not null,
25
+ amount numeric not null,
26
+ note text,
27
+ withdrawal_address text,
28
+ incoming_transaction text unique,
29
+ user_id int references users(id) not null,
30
+ other_user_id int references users(id)
31
+ )
32
+ ENDSQL
33
+ end
34
+
35
+ def get_or_create_user_id_for_username(username)
36
+ user_id = @db.get_first_value("select id from users where username = ?", [ username ])
37
+ unless user_id
38
+ @db.execute("insert into users(created_at, username) values (?, ?)", [ Time.now.to_i, username ])
39
+ user_id = @db.get_first_value("select last_insert_rowid()")
40
+ end
41
+
42
+ user_id
43
+ end
44
+
45
+ def get_username_for_user_id(user_id)
46
+ @db.get_first_value("select username from users where id = ?", [ user_id ])
47
+ end
48
+
49
+ def get_balance_for_user_id(user_id)
50
+ @db.get_first_value("select coalesce(sum(amount), 0) from transactions where user_id = ?", [ user_id ])
51
+ end
52
+
53
+ def get_transactions_for_user_id(user_id)
54
+ result = []
55
+ @db.execute("select t.id, t.created_at, t.amount, t.note, t.withdrawal_address, t.incoming_transaction, t.other_user_id, u.username from transactions t left outer join users u on t.other_user_id = u.id where user_id = ? order by t.created_at desc", [ user_id ]) do |row|
56
+ result << {
57
+ :id => row[0],
58
+ :created_at => row[1],
59
+ :amount => row[2],
60
+ :note => row[3],
61
+ :withdrawal_address => row[4],
62
+ :incoming_transaction => row[5],
63
+ :other_user_id => row[6],
64
+ :other_username => row[7]
65
+ }
66
+ end
67
+ result
68
+ end
69
+
70
+ def get_tipping_stats(from = nil)
71
+ # If no from is specified, use midnight of today.
72
+ if from.nil?
73
+ # TODO: this is hardcoding an offset of -0700 - fix that to make
74
+ # the timezone configurable
75
+ now = Time.now - 60 * 60 * 7
76
+ from = Time.local(now.year, now.month, now.day)
77
+ end
78
+ from = from.to_i
79
+
80
+ stats = {}
81
+
82
+ stats[:total_tipped] = @db.get_first_value("select coalesce(sum(amount), 0) from transactions t where t.other_user_id is not null and t.user_id <> t.other_user_id and t.amount < 0 and t.created_at > ?", [ from ])
83
+ stats[:total_tips] = @db.get_first_value("select count(*) from transactions t where t.other_user_id is not null and t.user_id <> t.other_user_id and t.amount < 0 and t.created_at > ?", [ from ])
84
+ stats[:tippers] = @db.execute("select * from (select username, sum(amount) total from transactions t, users u where t.user_id = u.id and other_user_id is not null and amount < 0 and user_id <> other_user_id and t.created_at >= ? group by username) foo order by total asc", [ from ])
85
+ stats[:tippees] = @db.execute("select * from (select username, sum(amount) total from transactions t, users u where t.user_id = u.id and amount > 0 and user_id <> other_user_id and t.created_at >= ? group by username) foo order by total desc", [ from ])
86
+ stats
87
+ end
88
+
89
+ # Returns an array of all the Bitcoin transaction ids for deposits
90
+ def get_incoming_transaction_ids
91
+ transaction_ids = []
92
+ @db.execute("select incoming_transaction from transactions where incoming_transaction is not null") do |row|
93
+ transaction_ids << row[0]
94
+ end
95
+
96
+ transaction_ids
97
+ end
98
+
99
+ # Adds a transaction with a deposit. Returns true if the row was
100
+ # added, and false if the insert failed for some reason (like if the
101
+ # transaction_id already exists).
102
+ def create_transaction_from_deposit(user_id, amount, transaction_id)
103
+ @db.execute("insert into transactions (created_at, amount, incoming_transaction, user_id) values (?, ?, ?, ?)",
104
+ [ Time.now.to_i, amount, transaction_id, user_id ])
105
+ return @db.changes == 1
106
+ end
107
+
108
+ # Adds a transaction for a withdrawal.
109
+ def create_transaction_from_withdrawal(user_id, amount, fee, address)
110
+ @db.transaction(:exclusive) do
111
+ # verify current balance
112
+ current_balance = self.get_balance_for_user_id(user_id)
113
+ if current_balance < (amount + fee)
114
+ raise InsufficientFundsError, "Insufficient funds; Current balance: #{current_balance} for amount #{amount} + fee #{fee}"
115
+ end
116
+
117
+ @db.execute("insert into transactions (created_at, user_id, amount, withdrawal_address)
118
+ values (?, ?, ?, ?)", [ Time.now.to_i, user_id, (0 - (amount + fee)), address ])
119
+ end
120
+
121
+ true
122
+ end
123
+
124
+ def create_transaction_from_tip(from_user_id, to_user_id, amount, message)
125
+ @db.transaction(:exclusive) do
126
+ # verify current balance
127
+ current_balance = self.get_balance_for_user_id(from_user_id)
128
+ if current_balance < amount
129
+ raise InsufficientFundsError, "Insufficient funds; Current balance: #{current_balance} for amount #{amount}"
130
+ end
131
+
132
+ now = Time.now.to_i
133
+ @db.execute("insert into transactions (created_at, user_id, amount, note, other_user_id)
134
+ values (?, ?, ?, ?, ?)", [ now, from_user_id, (0 - amount), message, to_user_id ])
135
+ @db.execute("insert into transactions (created_at, user_id, amount, note, other_user_id)
136
+ values (?, ?, ?, ?, ?)", [ now, to_user_id, amount, message, from_user_id ])
137
+ end
138
+
139
+ true
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,55 @@
1
+ module StyledIrcString
2
+ CODES = {
3
+ :color => "\x03",
4
+ :bold => "\x02",
5
+ :underline => "\x1f",
6
+ :inverse => "\x16",
7
+ :clear => "\x0f",
8
+ }
9
+
10
+ COLORS = {
11
+ 0 => %w(white),
12
+ 1 => %w(black),
13
+ 2 => %w(blue navy),
14
+ 3 => %w(green),
15
+ 4 => %w(red),
16
+ 5 => %w(brown maroon),
17
+ 6 => %w(purple),
18
+ 7 => %w(orange olive),
19
+ 8 => %w(yellow),
20
+ 9 => %w(light_green lime),
21
+ 10 => %w(teal a_green blue_cyan),
22
+ 11 => %w(light_cyan cyan aqua),
23
+ 12 => %w(light_blue royal),
24
+ 13 => %w(pink light_purple fuchsia),
25
+ 14 => %w(grey),
26
+ 15 => %w(light_grey silver),
27
+ }
28
+
29
+ def code_for_color(color)
30
+ COLORS.each do |code, colors|
31
+ if colors.include? color.to_s
32
+ return "%02d" % code
33
+ end
34
+ end
35
+
36
+ nil
37
+ end
38
+
39
+ def irc(attr1, attr2 = nil)
40
+ if CODES[attr1]
41
+ return "#{CODES[attr1]}#{self}#{CODES[:clear]}"
42
+ elsif fg_color = code_for_color(attr1)
43
+ bg_color = code_for_color(attr2)
44
+ bg_string = bg_color ? ",#{bg_color}" : ""
45
+
46
+ return "#{CODES[:color]}#{fg_color}#{bg_string}#{self}#{CODES[:clear]}"
47
+ end
48
+
49
+ self
50
+ end
51
+ end
52
+
53
+ class String
54
+ include StyledIrcString
55
+ end
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bitbot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Zach Wily
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-05-25 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: cinch
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
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
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: daemons
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: sqlite3
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: httparty
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: rspec
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ~>
84
+ - !ruby/object:Gem::Version
85
+ version: '2.5'
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ~>
92
+ - !ruby/object:Gem::Version
93
+ version: '2.5'
94
+ - !ruby/object:Gem::Dependency
95
+ name: yard
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ description:
111
+ email:
112
+ - zach@zwily.com
113
+ executables:
114
+ - bitbot
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - .gitignore
119
+ - Gemfile
120
+ - README.md
121
+ - Rakefile
122
+ - bin/bitbot
123
+ - bitbot.gemspec
124
+ - lib/bitbot/blockchain.rb
125
+ - lib/bitbot/bot.rb
126
+ - lib/bitbot/database.rb
127
+ - lib/ircstring.rb
128
+ homepage: http://github.com/zwily/bitbot
129
+ licenses: []
130
+ post_install_message:
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ none: false
136
+ requirements:
137
+ - - ! '>='
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ required_rubygems_version: !ruby/object:Gem::Requirement
141
+ none: false
142
+ requirements:
143
+ - - ! '>='
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubyforge_project:
148
+ rubygems_version: 1.8.23
149
+ signing_key:
150
+ specification_version: 3
151
+ summary: Bitcoin IRC Tip Bot
152
+ test_files: []
153
+ has_rdoc: