bitbot 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/bin/bitbot CHANGED
@@ -3,8 +3,7 @@
3
3
  require 'daemons'
4
4
  require 'yaml'
5
5
 
6
- require 'bitbot/bot'
7
- require 'bitbot/database'
6
+ require 'bitbot/plugin'
8
7
 
9
8
  def usage
10
9
  puts "Usage: #{File.basename($0)} {start|stop|run} <path to config.yml>"
@@ -27,9 +26,6 @@ unless File.exist?(File.dirname(config['data']['path']))
27
26
  exit 1
28
27
  end
29
28
 
30
- # Update the database file
31
- Bitbot::Database.new(File.join(config['data']['path'], "bitbot.db")).upgrade_schema()
32
-
33
29
  Daemons.run_proc('bitbot',
34
30
  :hard_exit => true,
35
31
  :backtrace => true,
@@ -38,19 +34,19 @@ Daemons.run_proc('bitbot',
38
34
  :monitor => true,
39
35
  :log_output => true) do
40
36
 
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
37
+ bot = Cinch::Bot.new do
38
+ configure do |c|
39
+ c.server = config['irc']['server']
40
+ c.port = config['irc']['port']
41
+ c.ssl.use = config['irc']['ssl']
42
+ c.channels = config['irc']['channels']
43
+ c.nick = config['irc']['nick'] || 'bitbot'
44
+ c.user = config['irc']['username'] || 'bitbot'
45
+ c.password = config['irc']['password']
46
+ c.verbose = config['irc']['verbose']
47
+
48
+ c.plugins.plugins = [Bitbot::Plugin]
49
+ c.plugins.options[Bitbot::Plugin] = config
54
50
  end
55
51
  end
56
52
 
data/bitbot.gemspec CHANGED
@@ -2,7 +2,7 @@ $:.push File.expand_path("../lib", __FILE__)
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = %q{bitbot}
5
- s.version = "0.0.1"
5
+ s.version = "0.0.2"
6
6
  s.platform = Gem::Platform::RUBY
7
7
  s.authors = ["Zach Wily"]
8
8
  s.email = ["zach@zwily.com"]
@@ -78,7 +78,7 @@ module Bitbot
78
78
  from = from.to_i
79
79
 
80
80
  stats = {}
81
-
81
+
82
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
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
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 ])
@@ -0,0 +1,63 @@
1
+ require 'cinch'
2
+ require 'ircstring'
3
+
4
+ require 'bitbot/database'
5
+ require 'bitbot/plugin/common'
6
+ require 'bitbot/plugin/help'
7
+ require 'bitbot/plugin/balance'
8
+ require 'bitbot/plugin/deposit'
9
+ require 'bitbot/plugin/history'
10
+ require 'bitbot/plugin/tip'
11
+ require 'bitbot/plugin/tipstats'
12
+ require 'bitbot/plugin/withdraw'
13
+ require 'bitbot/plugin/update_addresses'
14
+ require 'bitbot/plugin/update_exchange_rates'
15
+
16
+ class Bitbot::Plugin
17
+ include Cinch::Plugin
18
+
19
+ include Bitbot::Common
20
+ include Bitbot::Help
21
+ include Bitbot::Balance
22
+ include Bitbot::Deposit
23
+ include Bitbot::History
24
+ include Bitbot::Tip
25
+ include Bitbot::TipStats
26
+ include Bitbot::Withdraw
27
+ include Bitbot::UpdateAddresses
28
+ include Bitbot::UpdateExchangeRates
29
+
30
+ def initialize(bot)
31
+ super
32
+ Bitbot::Database.new(File.join(config['data']['path'], "bitbot.db")).upgrade_schema()
33
+ end
34
+
35
+ set :prefix, ""
36
+
37
+ #
38
+ # Private messages
39
+ #
40
+ match /^help(.*)$/, :method => :on_help, :react_on => :private
41
+ match /^balance$/, :method => :on_balance, :react_on => :private
42
+ match /^history$/, :method => :on_history, :react_on => :private
43
+ match /^withdraw(.*)$/, :method => :on_withdraw, :react_on => :private
44
+ match /^deposit$/, :method => :on_deposit, :react_on => :private
45
+
46
+ #
47
+ # Channel messages
48
+ #
49
+ match /^\+tipstats$/, :method => :on_tipstats, :react_on => :channel
50
+ match /^\+tip\s+(\w+)\s+([\d.]+)\s+?(.*)/, :method => :on_tip, :react_on => :channel
51
+
52
+ #
53
+ # Timer jobs
54
+ #
55
+ timer 60, :method => :on_update_exchange_rates
56
+ timer 60, :method => :on_update_addresses
57
+
58
+ #
59
+ # Also run the timer jobs on connect
60
+ #
61
+ listen_to :connect, :method => :on_update_exchange_rates
62
+ listen_to :connect, :method => :on_update_addresses
63
+ end
@@ -0,0 +1,7 @@
1
+ module Bitbot::Balance
2
+ def on_balance(m)
3
+ user_id = db.get_or_create_user_id_for_username(m.user.user)
4
+ m.reply "Balance is #{satoshi_with_usd(db.get_balance_for_user_id(user_id))}"
5
+ end
6
+ end
7
+
@@ -0,0 +1,88 @@
1
+ # coding: utf-8
2
+
3
+ require 'ircstring'
4
+ require 'bitbot/database'
5
+ require 'bitbot/blockchain'
6
+
7
+ module Bitbot::Common
8
+ def cached_addresses
9
+ @cached_addresses
10
+ end
11
+
12
+ def cached_addresses=(new)
13
+ @cached_addresses = new
14
+ end
15
+
16
+ def exchange_rate(currency)
17
+ if @cached_exchange_rates
18
+ return @cached_exchange_rates[currency]["15m"]
19
+ end
20
+ nil
21
+ end
22
+
23
+ def exchange_rates=(new)
24
+ @cached_exchange_rates = new
25
+ end
26
+
27
+ #
28
+ # Returns a database handle for use in the current thread.
29
+ #
30
+ def db
31
+ Thread.current[:bitbot_db] ||=
32
+ Bitbot::Database.new(File.join(config['data']['path'], "bitbot.db"))
33
+ end
34
+
35
+ #
36
+ # Returns a Blockchain API helper. Everyone uses the same one,
37
+ # but it doesn't keep any state so it's fine.
38
+ #
39
+ def blockchain
40
+ @@cached_blockchain ||=
41
+ Bitbot::Blockchain.new(config['blockchain']['wallet_id'],
42
+ config['blockchain']['password1'],
43
+ config['blockchain']['password2'])
44
+ end
45
+
46
+ #
47
+ # Returns the User for the given username. If you want to get
48
+ # a User based on nick, User(nick) is easier.
49
+ #
50
+ def user_with_username(username)
51
+ bot.user_list.each do |user|
52
+ return user if user.user == username
53
+ end
54
+ nil
55
+ end
56
+
57
+ #
58
+ # Some number formatting helpers
59
+ #
60
+ def satoshi_to_str(satoshi)
61
+ str = "฿%.8f" % (satoshi.to_f / 10**8)
62
+ # strip trailing 0s
63
+ str.gsub(/0*$/, '')
64
+ end
65
+
66
+ def satoshi_to_usd(satoshi)
67
+ rate = exchange_rate("USD")
68
+ if rate
69
+ "$%.2f" % (satoshi.to_f / 10**8 * rate)
70
+ else
71
+ "$?"
72
+ end
73
+ end
74
+
75
+ def satoshi_with_usd(satoshi)
76
+ btc_str = satoshi_to_str(satoshi)
77
+ if satoshi < 0
78
+ btc_str = btc_str.irc(:red)
79
+ else
80
+ btc_str = btc_str.irc(:green)
81
+ end
82
+
83
+ usd_str = "[".irc(:grey) + satoshi_to_usd(satoshi).irc(:blue) + "]".irc(:grey)
84
+
85
+ "#{btc_str} #{usd_str}"
86
+ end
87
+ end
88
+
@@ -0,0 +1,33 @@
1
+ module Bitbot::Deposit
2
+ def on_deposit(m, create = true)
3
+ user_id = db.get_or_create_user_id_for_username(m.user.user)
4
+
5
+ unless cached_addresses
6
+ m.reply "Bitbot is not initialized yet. Please try again later."
7
+ return
8
+ end
9
+
10
+ if address = cached_addresses[user_id]
11
+ m.reply "Send deposits to #{address["address"].irc(:bold)}. " +
12
+ "This address is specific to you, and any funds delivered " +
13
+ "to it will be added to your account after confirmation."
14
+ return
15
+ end
16
+
17
+ unless create
18
+ m.reply "There was a problem getting your deposit address. " +
19
+ "Please contact your friendly Bitbot admin."
20
+ return
21
+ end
22
+
23
+ # Attempt to create an address.
24
+ blockchain.create_deposit_address_for_user_id(user_id)
25
+
26
+ # Force a refresh of the cached address list...
27
+ on_update_addresses
28
+
29
+ # Now run again, to show them the address we just looked up.
30
+ on_deposit(m, false)
31
+ end
32
+ end
33
+
@@ -0,0 +1,5 @@
1
+ module Bitbot::Help
2
+ def on_help(m, args)
3
+ m.reply "Commands: balance, history, withdraw, deposit, +tip, +tipstats"
4
+ end
5
+ end
@@ -0,0 +1,27 @@
1
+ module Bitbot::History
2
+ def on_history(m)
3
+ user_id = db.get_or_create_user_id_for_username(m.user.user)
4
+
5
+ on_balance(m)
6
+
7
+ n = 0
8
+ db.get_transactions_for_user_id(user_id).each do |tx|
9
+ time = Time.at(tx[:created_at].to_i).strftime("%Y-%m-%d")
10
+ amount = satoshi_with_usd(tx[:amount])
11
+ action = if tx[:amount] < 0 && tx[:other_user_id]
12
+ "to #{tx[:other_username]}"
13
+ elsif tx[:amount] > 0 && tx[:other_user_id]
14
+ "from #{tx[:other_username]}"
15
+ elsif tx[:withdrawal_address]
16
+ "withdrawal to #{tx[:withdrawal_address]}"
17
+ elsif tx[:incoming_transaction]
18
+ "deposit from tx #{tx[:incoming_transaction]}"
19
+ end
20
+
21
+ m.reply "#{time.irc(:grey)}: #{amount} #{action} #{"(#{tx[:note]})".irc(:grey) if tx[:note]}"
22
+
23
+ n += 1
24
+ break if n >= 10
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,61 @@
1
+ # coding: utf-8
2
+
3
+ module Bitbot::Tip
4
+ def on_tip(m, recipient, amount, message)
5
+ # Look up sender
6
+ user_id = db.get_or_create_user_id_for_username(m.user.user)
7
+
8
+ # Look up recipient
9
+ recipient_ircuser = m.channel.users.keys.find {|u| u.name == recipient }
10
+ unless recipient_ircuser
11
+ m.user.msg("Could not find #{recipient} in the channel list.")
12
+ return
13
+ end
14
+ recipient_user_id = db.get_or_create_user_id_for_username(recipient_ircuser.user)
15
+
16
+ # Convert amount to satoshi
17
+ satoshi = (amount.to_f * 10**8).to_i
18
+ if satoshi <= 0
19
+ m.user.msg("Cannot send a negative amount.")
20
+ return
21
+ end
22
+
23
+ # Attempt the transaction (will raise on InsufficientFunds)
24
+ begin
25
+ db.create_transaction_from_tip(user_id, recipient_user_id, satoshi, message)
26
+ rescue Bitbot::InsufficientFundsError
27
+ m.reply "Insufficient funds! It's the thought that counts.", true
28
+ return
29
+ end
30
+
31
+ # Success! Let the room know...
32
+ m.reply "[✔] Verified: ".irc(:grey).irc(:bold) +
33
+ m.user.user.irc(:bold) +
34
+ " ➜ ".irc(:grey) +
35
+ satoshi_with_usd(satoshi) +
36
+ " ➜ ".irc(:grey) +
37
+ recipient_ircuser.user.irc(:bold)
38
+
39
+ # ... and let the sender know privately ...
40
+ m.user.msg "You just sent " +
41
+ recipient_ircuser.user.irc(:bold) + " " +
42
+ satoshi_with_usd(satoshi) +
43
+ " in " +
44
+ m.channel.name.irc(:bold) +
45
+ " bringing your balance to " +
46
+ satoshi_with_usd(db.get_balance_for_user_id(user_id)) +
47
+ "."
48
+
49
+ # ... and let the recipient know privately.
50
+ recipient_ircuser.msg m.user.user.irc(:bold) +
51
+ " just sent you " +
52
+ satoshi_with_usd(satoshi) +
53
+ " in " +
54
+ m.channel.name.irc(:bold) +
55
+ " bringing your balance to " +
56
+ satoshi_with_usd(db.get_balance_for_user_id(recipient_user_id)) +
57
+ ". Type 'help' to list bitbot commands."
58
+ end
59
+ end
60
+
61
+
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+
3
+ module Bitbot::TipStats
4
+ def on_tipstats(m)
5
+ stats = db.get_tipping_stats
6
+
7
+ str = "Stats: ".irc(:grey) +
8
+ "tips today: " +
9
+ satoshi_with_usd(0 - stats[:total_tipped]) + " " +
10
+ "(#{stats[:total_tips]} tips) "
11
+
12
+ if stats[:tippers].length > 0
13
+ str += "biggest tipper: ".irc(:black) +
14
+ stats[:tippers][0][0].irc(:bold) +
15
+ " (#{satoshi_with_usd(0 - stats[:tippers][0][1])}) "
16
+ end
17
+
18
+ if stats[:tippees].length > 0
19
+ str += "biggest recipient: ".irc(:black) +
20
+ stats[:tippees][0][0].irc(:bold) +
21
+ " (#{satoshi_with_usd(stats[:tippees][0][1])}) "
22
+ end
23
+
24
+ m.reply str
25
+ end
26
+ end
27
+
28
+
29
+
@@ -0,0 +1,112 @@
1
+ module Bitbot::UpdateAddresses
2
+ def cache_file_path
3
+ File.join(config['data']['path'], "cached_addresses.yml")
4
+ end
5
+
6
+ def on_update_addresses(event = nil)
7
+ if cached_addresses.nil?
8
+ # Load from the cache, if available, on first load
9
+ self.cached_addresses = YAML.load(File.read(cache_file_path)) rescue nil
10
+ end
11
+
12
+ # Updates the cached map of depositing addresses.
13
+ new_addresses = {}
14
+ all_addresses = []
15
+
16
+ addresses = blockchain.get_addresses_in_wallet()
17
+ addresses.each do |address|
18
+ all_addresses << address["address"]
19
+ next unless address["label"] =~ /^\d+$/
20
+
21
+ user_id = address["label"].to_i
22
+
23
+ new_addresses[user_id] = address
24
+
25
+ # We set a flag on the address saying we need to get the
26
+ # confirmed balance IF the previous entry has the flag, OR
27
+ # the address is new OR if the balance does not equal the
28
+ # previous balance. We only clear the field when the balance
29
+ # equals the confirmed balance.
30
+ address["need_confirmed_balance"] = @cached_addresses[user_id]["need_confirmed_balance"] rescue true
31
+ if address["balance"] != (@cached_addresses[user_id]["balance"] rescue nil)
32
+ address["need_confirmed_balance"] = true
33
+ end
34
+ end
35
+
36
+ # Now go through new addresses, performing any confirmation checks
37
+ # for flagged ones.
38
+ new_addresses.each do |user_id, address|
39
+ if address["need_confirmed_balance"]
40
+ balance = blockchain.get_balance_for_address(address["address"])
41
+ address["confirmed_balance"] = balance
42
+
43
+ if address["confirmed_balance"] == address["balance"]
44
+ address["need_confirmed_balance"] = false
45
+ end
46
+
47
+ # Process any transactions for this address
48
+ process_new_transactions_for_address(address, user_id, all_addresses)
49
+ end
50
+ end
51
+
52
+ # Thread-safe? Sure, why not.
53
+ self.cached_addresses = new_addresses
54
+
55
+ # Cache them on disk for faster startups
56
+ File.write(cache_file_path, YAML.dump(new_addresses))
57
+ end
58
+
59
+ def process_new_transactions_for_address(address, user_id, all_addresses)
60
+ existing_transactions = {}
61
+ db.get_incoming_transaction_ids.each do |txid|
62
+ existing_transactions[txid] = true
63
+ end
64
+
65
+ response = blockchain.get_details_for_address(address["address"])
66
+
67
+ username = db.get_username_for_user_id(user_id)
68
+
69
+ response["txs"].each do |tx|
70
+ # Skip ones we already have in the database
71
+ next if existing_transactions[tx["hash"]]
72
+
73
+ # Skip any transactions that have an existing bitbot address
74
+ # as an input
75
+ if tx["inputs"].any? {|input| all_addresses.include? input["prev_out"]["addr"] }
76
+ debug "Skipping tx with bitbot input address: #{tx["hash"]}"
77
+ next
78
+ end
79
+
80
+ # find the total amount for this address
81
+ amount = 0
82
+ tx["out"].each do |out|
83
+ if out["addr"] == address["address"]
84
+ amount += out["value"]
85
+ end
86
+ end
87
+
88
+ # Skip unless it's in a block (>=1 confirmation)
89
+ if !tx["block_height"] || tx["block_height"] == 0
90
+ # TODO: only tell them this one time.
91
+ if ircuser = user_with_username(username)
92
+ ircuser.msg "Waiting for confirmation of transaction of " +
93
+ satoshi_with_usd(amount) +
94
+ " in transaction #{tx["hash"].irc(:grey)}"
95
+ end
96
+ next
97
+ end
98
+
99
+ # There is a unique constraint on incoming_transaction, so this
100
+ # will fail if for some reason we try to add it again.
101
+ if db.create_transaction_from_deposit(user_id, amount, tx["hash"])
102
+ # Notify the depositor, if they're around
103
+ if ircuser = user_with_username(username)
104
+ ircuser.msg "Received deposit of " +
105
+ satoshi_with_usd(amount) + ". Current balance is " +
106
+ satoshi_with_usd(db.get_balance_for_user_id(user_id)) + "."
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+
@@ -0,0 +1,5 @@
1
+ module Bitbot::UpdateExchangeRates
2
+ def on_update_exchange_rates(event = nil)
3
+ self.exchange_rates = blockchain.get_exchange_rates()
4
+ end
5
+ end
@@ -0,0 +1,37 @@
1
+ module Bitbot::Withdraw
2
+ def on_withdraw(m, args)
3
+ if args =~ /\s+([\d.]+)\s+([13][0-9a-zA-Z]{26,35})/
4
+ amount = $1.to_f
5
+ address = $2
6
+
7
+ user_id = db.get_or_create_user_id_for_username(m.user.user)
8
+ satoshi = (amount * 10**8).to_i
9
+
10
+ # Perform the local transaction in the database. Note that we
11
+ # don't do the blockchain update in the transaction, because we
12
+ # don't want to roll back the transaction if the blockchain update
13
+ # *appears* to fail. It might look like it failed, but really
14
+ # succeed, letting someone withdraw money twice.
15
+ # TODO: don't hardcode fee
16
+ begin
17
+ db.create_transaction_from_withdrawal(user_id, satoshi, 50000, address)
18
+ rescue Bitbot::InsufficientFundsError
19
+ m.reply "You don't have enough to withdraw #{satoshi_to_str(satoshi)} + 0.0005 fee"
20
+ return
21
+ end
22
+
23
+ response = blockchain.create_payment(address, satoshi, 50000)
24
+ if response["tx_hash"]
25
+ m.reply "Sent #{satoshi_with_usd(satoshi)} to #{address.irc(:bold)} " +
26
+ "in transaction #{response["tx_hash"].irc(:grey)}."
27
+ else
28
+ m.reply "Something may have gone wrong with your withdrawal. Please contact " +
29
+ "your friendly Bitbot administrator to investigate where your money is."
30
+ end
31
+ else
32
+ m.reply "Usage: withdraw <amount in BTC> <address>"
33
+ m.reply "0.0005 BTC will also be withdrawn for the transaction fee."
34
+ end
35
+ end
36
+ end
37
+
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitbot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-05-25 00:00:00.000000000 Z
12
+ date: 2013-05-26 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: cinch
@@ -122,8 +122,18 @@ files:
122
122
  - bin/bitbot
123
123
  - bitbot.gemspec
124
124
  - lib/bitbot/blockchain.rb
125
- - lib/bitbot/bot.rb
126
125
  - lib/bitbot/database.rb
126
+ - lib/bitbot/plugin.rb
127
+ - lib/bitbot/plugin/balance.rb
128
+ - lib/bitbot/plugin/common.rb
129
+ - lib/bitbot/plugin/deposit.rb
130
+ - lib/bitbot/plugin/help.rb
131
+ - lib/bitbot/plugin/history.rb
132
+ - lib/bitbot/plugin/tip.rb
133
+ - lib/bitbot/plugin/tipstats.rb
134
+ - lib/bitbot/plugin/update_addresses.rb
135
+ - lib/bitbot/plugin/update_exchange_rates.rb
136
+ - lib/bitbot/plugin/withdraw.rb
127
137
  - lib/ircstring.rb
128
138
  homepage: http://github.com/zwily/bitbot
129
139
  licenses: []
data/lib/bitbot/bot.rb DELETED
@@ -1,404 +0,0 @@
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