bitbot 0.0.1

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,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: