# coding: utf-8 require 'cinch' require 'bitbot/database' require 'bitbot/blockchain' require 'ircstring' module Bitbot class Bot < Cinch::Bot def initialize(config = {}) @bot_config = config @cached_addresses = nil @cached_exchange_rates = nil super() do configure do |c| c.server = config['irc']['server'] c.port = config['irc']['port'] c.ssl.use = config['irc']['ssl'] c.channels = config['irc']['channels'] c.nick = config['irc']['nick'] || 'bitbot' c.user = config['irc']['username'] || 'bitbot' c.password = config['irc']['password'] c.verbose = config['irc']['verbose'] end on :private, /^help$/ do |m| self.bot.command_help(m) end on :private, /^balance/ do |m| self.bot.command_balance(m) end on :private, /^history/ do |m| self.bot.command_history(m) end on :private, /^withdraw$/ do |m| m.reply "Usage: withdraw <amount in BTC> <address>" end on :private, /^withdraw\s+([\d.]+)\s+([13][0-9a-zA-Z]{26,35})/ do |m, amount, address| self.bot.command_withdraw(m, amount, address) end on :private, /^deposit/ do |m| self.bot.command_deposit(m) end on :channel, /^\+tipstats$/ do |m| self.bot.command_tipstats(m) end on :channel, /^\+tip\s+(\w+)\s+([\d.]+)\s+?(.*)/ do |m, recipient, amount, message| self.bot.command_tip(m, recipient, amount, message) end end end def get_db Bitbot::Database.new(File.join(@bot_config['data']['path'], "bitbot.db")) end def get_blockchain Bitbot::Blockchain.new(@bot_config['blockchain']['wallet_id'], @bot_config['blockchain']['password1'], @bot_config['blockchain']['password2']) end def satoshi_to_str(satoshi) str = "฿%.8f" % (satoshi.to_f / 10**8) # strip trailing 0s str.gsub(/0*$/, '') end def satoshi_to_usd(satoshi) if @cached_exchange_rates && @cached_exchange_rates["USD"] "$%.2f" % (satoshi.to_f / 10**8 * @cached_exchange_rates["USD"]["15m"]) else "$?" end end def satoshi_with_usd(satoshi) btc_str = satoshi_to_str(satoshi) if satoshi < 0 btc_str = btc_str.irc(:red) else btc_str = btc_str.irc(:green) end usd_str = "[".irc(:grey) + satoshi_to_usd(satoshi).irc(:blue) + "]".irc(:grey) "#{btc_str} #{usd_str}" end # This should be called periodically to keep exchange rates up to # date. def update_exchange_rates @cached_exchange_rates = get_blockchain().get_exchange_rates() end # This method needs to be called periodically, like every minute in # order to process new transactions. def update_addresses cache_file = File.join(@bot_config['data']['path'], "cached_addresses.yml") if @cached_addresses.nil? # Load from the cache, if available, on first load @cached_addresses = YAML.load(File.read(cache_file)) rescue nil end blockchain = get_blockchain() # Updates the cached map of depositing addresses. new_addresses = {} all_addresses = [] addresses = blockchain.get_addresses_in_wallet() addresses.each do |address| all_addresses << address["address"] next unless address["label"] =~ /^\d+$/ user_id = address["label"].to_i new_addresses[user_id] = address # We set a flag on the address saying we need to get the # confirmed balance IF the previous entry has the flag, OR # the address is new OR if the balance does not equal the # previous balance. We only clear the field when the balance # equals the confirmed balance. address["need_confirmed_balance"] = @cached_addresses[user_id]["need_confirmed_balance"] rescue true if address["balance"] != (@cached_addresses[user_id]["balance"] rescue nil) address["need_confirmed_balance"] = true end end # Now go through new addresses, performing any confirmation checks # for flagged ones. new_addresses.each do |user_id, address| if address["need_confirmed_balance"] balance = blockchain.get_balance_for_address(address["address"]) address["confirmed_balance"] = balance if address["confirmed_balance"] == address["balance"] address["need_confirmed_balance"] = false end # Process any transactions for this address self.process_new_transactions_for_address(address, user_id, all_addresses) end end # Thread-safe? Sure, why not. @cached_addresses = new_addresses # Cache them on disk for faster startups File.write(cache_file, YAML.dump(@cached_addresses)) end def process_new_transactions_for_address(address, user_id, all_addresses) db = get_db() blockchain = get_blockchain() existing_transactions = {} db.get_incoming_transaction_ids().each do |txid| existing_transactions[txid] = true end response = blockchain.get_details_for_address(address["address"]) username = db.get_username_for_user_id(user_id) ircuser = self.user_with_username(username) response["txs"].each do |tx| # Skip ones we already have in the database next if existing_transactions[tx["hash"]] # Skip any transactions that have an existing bitbot address # as an input if tx["inputs"].any? {|input| all_addresses.include? input["prev_out"]["addr"] } debug "Skipping tx with bitbot input address: #{tx["hash"]}" next end # find the total amount for this address amount = 0 tx["out"].each do |out| if out["addr"] == address["address"] amount += out["value"] end end # Skip unless it's in a block (>=1 confirmation) if !tx["block_height"] || tx["block_height"] == 0 ircuser.msg "Waiting for confirmation of transaction of " + satoshi_with_usd(amount) + " in transaction #{tx["hash"].irc(:grey)}" next end # There is a unique constraint on incoming_transaction, so this # will fail if for some reason we try to add it again. if db.create_transaction_from_deposit(user_id, amount, tx["hash"]) # Notify the depositor if ircuser ircuser.msg "Received deposit of " + satoshi_with_usd(amount) + ". Current balance is " + satoshi_with_usd(db.get_balance_for_user_id(user_id)) + "." end end end end def user_with_username(username) self.bot.user_list.each do |user| return user if user.user == username end end def command_help(msg) msg.reply "Commands: balance, history, withdraw, deposit, +tip, +tipstats" end def command_balance(msg) db = get_db() user_id = db.get_or_create_user_id_for_username(msg.user.user) msg.reply "Balance is #{satoshi_with_usd(db.get_balance_for_user_id(user_id))}" end def command_deposit(msg, create = true) db = get_db() user_id = db.get_or_create_user_id_for_username(msg.user.user) unless @cached_addresses msg.reply "Bitbot is not initialized yet. Please try again later." return end if address = @cached_addresses[user_id] msg.reply "Send deposits to #{address["address"].irc(:bold)}. " + "This address is specific to you, and any funds delivered " + "to it will be added to your account after confirmation." return end unless create msg.reply "There was a problem getting your deposit address. " + "Please contact your friends Bitbot admin." return end # Attempt to create an address. blockchain = get_blockchain() blockchain.create_deposit_address_for_user_id(user_id) # Force a refresh of the cached address list... self.update_addresses() self.command_deposit(msg, false) end def command_history(msg) db = get_db() user_id = db.get_or_create_user_id_for_username(msg.user.user) command_balance(msg) n = 0 db.get_transactions_for_user_id(user_id).each do |tx| time = Time.at(tx[:created_at].to_i).strftime("%Y-%m-%d") amount = satoshi_with_usd(tx[:amount]) action = if tx[:amount] < 0 && tx[:other_user_id] "to #{tx[:other_username]}" elsif tx[:amount] > 0 && tx[:other_user_id] "from #{tx[:other_username]}" elsif tx[:withdrawal_address] "withdrawal to #{tx[:withdrawal_address]}" elsif tx[:incoming_transaction] "deposit from tx #{tx[:incoming_transaction]}" end msg.reply "#{time.irc(:grey)}: #{amount} #{action} #{"(#{tx[:note]})".irc(:grey) if tx[:note]}" n += 1 break if n >= 10 end end def command_withdraw(msg, amount, address) db = get_db() user_id = db.get_or_create_user_id_for_username(msg.user.user) satoshi = (amount.to_f * 10**8).to_i # Perform the local transaction in the database. Note that we # don't do the blockchain update in the transaction, because we # don't want to roll back the transaction if the blockchain update # *appears* to fail. It might look like it failed, but really # succeed, letting someone withdraw money twice. # TODO: don't hardcode fee begin db.create_transaction_from_withdrawal(user_id, satoshi, 500000, address) rescue InsufficientFundsError msg.reply "You don't have enough to withdraw #{satoshi_to_str(satoshi)} + 0.0005 fee" return end blockchain = get_blockchain() response = blockchain.create_payment(address, satoshi, 500000) if response["tx_hash"] msg.reply "Sent #{satoshi_with_usd(satoshi)} to #{address.irc(:bold)} " + "in transaction #{response["tx_hash"].irc(:grey)}." else msg.reply "Something may have gone wrong with your withdrawal. Please contact " + "your friendly Bitbot administrator to investigate where your money is." end end def command_tipstats(msg) db = get_db() stats = db.get_tipping_stats str = "Stats: ".irc(:grey) + "tips today: " + satoshi_with_usd(0 - stats[:total_tipped]) + " " + "#{stats[:total_tips]} tips " if stats[:tippers].length > 0 str += "biggest tipper: ".irc(:black) + stats[:tippers][0][0].irc(:bold) + " (#{satoshi_with_usd(0 - stats[:tippers][0][1])}) " end if stats[:tippees].length > 0 str += "biggest recipient: ".irc(:black) + stats[:tippees][0][0].irc(:bold) + " (#{satoshi_with_usd(stats[:tippees][0][1])}) " end msg.reply str end def command_tip(msg, recipient, amount, message) db = get_db() # Look up sender user_id = db.get_or_create_user_id_for_username(msg.user.user) # Look up recipient recipient_ircuser = msg.channel.users.keys.find {|u| u.name == recipient } unless recipient_ircuser msg.user.msg("Could not find #{recipient} in the channel list.") return end recipient_user_id = db.get_or_create_user_id_for_username(recipient_ircuser.user) # Convert amount to satoshi satoshi = (amount.to_f * 10**8).to_i if satoshi <= 0 msg.user.msg("Cannot send a negative amount.") return end # Attempt the transaction (will raise on InsufficientFunds) begin db.create_transaction_from_tip(user_id, recipient_user_id, satoshi, message) rescue InsufficientFundsError msg.reply "Insufficient funds! It's the thought that counts.", true return end # Success! Let the room know... msg.reply "[✔] Verified: ".irc(:grey).irc(:bold) + msg.user.user.irc(:bold) + " ➜ ".irc(:grey) + satoshi_with_usd(satoshi) + " ➜ ".irc(:grey) + recipient_ircuser.user.irc(:bold) # ... and let the sender know privately ... msg.user.msg "You just sent " + recipient_ircuser.user.irc(:bold) + " " + satoshi_with_usd(satoshi) + " in " + msg.channel.name.irc(:bold) + " bringing your balance to " + satoshi_with_usd(db.get_balance_for_user_id(user_id)) + "." # ... and let the recipient know privately. recipient_ircuser.msg msg.user.user.irc(:bold) + " just sent you " + satoshi_with_usd(satoshi) + " in " + msg.channel.name.irc(:bold) + " bringing your balance to " + satoshi_with_usd(db.get_balance_for_user_id(recipient_user_id)) + ". Type 'help' to list bitbot commands." end end end