Simon Volpert addrwatch / master addrwatch
master

Tree @master (Download .tar.gz)

addrwatch @masterraw · history · blame

#!/usr/bin/env python3
# addrwatch - A Bitcoin Cash address watcher and notifier
# Author: Simon Volpert <simon@simonvolpert.com>
# Project page: https://simonvolpert.com/addrwatch/
# This program is free software, released under the Apache License, Version 2.0. See the LICENSE file for more information
# Consult the README file for usage instructions and other helpful hints

import os
import sys
import subprocess
import time

import bch # Local library file
import sendmail # Local library file

usage = '''Usage: addrwatch [CONFIG_FILE]
See the README file for more information.'''

email_from = 'addrwatch <noreply@localhost.localdomain>'
rate_cache_time = 3600

text_body = [
	'Total address balance: %s',
	'Total address balance (%s): %s',
	'Unconfirmed balance: %s',
	'Unconfirmed balance (%s): %s',
	'Confirmed balance: %s',
	'Confirmed balance (%s): %s'
]

# Look for a configuration file
config_file_locations = [
	os.path.join(os.path.expanduser('~'), '.addrwatch.cfg'),
	os.path.join(sys.path[0], 'addrwatch.cfg'),
	'addrwatch.cfg'
]
if len(sys.argv) > 1:
	if sys.argv[1] == '-h' or sys.argv[1] == '--help':
		print(usage)
		sys.exit(0)
	else:
		config_file_locations.insert(0, sys.argv[1])
# Try to open the configuration file
cfg_file = None
for file_name in config_file_locations:
	try:
		f = open(file_name, 'r')
		cfg_file = file_name
	except:
		continue
	else:
		print('Using %s as config file' % cfg_file)
		break
if cfg_file is None:
	print('''Could not open the configuration file in any of the checked locations.
Please copy addrwatch.cfg.sample to addrwatch.cfg or $HOME/.addrwatch.cfg and
edit it to your liking, or run addrwatch with a file name as the only argument.''')
	sys.exit(1)
# Read the configuration file
lines = f.readlines()
f.close()

# Parse config file
config = {}
addresses = {}
for line in lines:
	# Skip blank lines and comments
	if line.strip() == '' or line.startswith('#'):
		continue
	# Split to key and value pairs
	words = line.strip().split('=')
	key = words[0].strip()
	value = '='.join(words[1:]).strip()
	if key == 'address':
		value = value.split(',')
		if len(value) == 0:
			continue
		elif len(value) == 1:
			value.append(0)
		else:
			try:
				value[1] = float(value[1])
			except ValueError:
				value[1] = 0
		addresses[value[0]] = value[1]
	else:
		config[key] = value

# Sanitize configuration
try:
	config['currency'] = config['currency'].upper()
except:
	config['currency'] = ''
try:
	config['frequency'] = int(config['frequency'])
	if config['frequency'] < 0:
		raise ValueError
except:
	print('Invalid or missing frequency setting, using defaults')
	config['frequency'] = 60
try:
	if config['notification'].lower() == 'yes':
		config['notification'] = True
	else:
		raise ValueError
except:
	config['notification'] = False
try:
	if config['unconfirmed'].lower() == 'yes':
		config['unconfirmed'] = True
	else:
		raise ValueError
except:
	config['unconfirmed'] = False
if 'email' not in config:
	config['email'] = ''
if 'email_from' not in config.keys() or config['email_from'] == '':
	config['email_from'] = email_from

while True:
	# Delay after the first pass
	try:
		balance
	except NameError:
		balance = 0
		loop_time = rate_cache_time
	else:
		loop_time += config['frequency']
		try:
			time.sleep(config['frequency'])
		except KeyboardInterrupt:
			sys.exit()
	# Pull exchange rate if needed
	if config['currency'] == '':
		rate = 1
	elif loop_time >= rate_cache_time:
		try:
			rate = bch.get_price(config['currency'])
			loop_time = 0
		except KeyboardInterrupt:
			sys.exit()
		except:
			print('Could not load conversion rate: %s' % sys.exc_info()[1])
			if config['frequency'] == 0:
				sys.exit(0)
			continue
	# Pull address balances from block explorer
	for addr in addresses.keys():
		try:
			balance, unconfirmed = bch.get_balance(addr)
		except KeyboardInterrupt:
			sys.exit()
		except:
			exception = sys.exc_info()[1]
			try:
				print('Could not load address balance: %s' % exception.reason)
			except AttributeError:
				print('Could not load address balance: %s' % exception)
			if config['frequency'] == 0:
				sys.exit(0)
			break
		# Calculate balance change
		if config['unconfirmed']:
			balance += unconfirmed
		report = ''
		if balance != addresses[addr]:
			if config['currency'] != '':
				diff = bch.fiat((balance - addresses[addr]) * rate)
			else:
				diff = bch.btc(balance - addresses[addr])
			if diff.startswith('-'):
				report = 'Payment sent: %s %s from address %s' % (diff, config['currency'], addr)
			else:
				report = 'Payment received: %s %s to address %s' % (diff, config['currency'], addr)
		# Report balance change
		if report != '':
			# Desktop notification
			if config['notification'] and 'DISPLAY' in os.environ:
				subprocess.call(['notify-send', report])
			# Email message
			if config['email'] != '':
				# Format the message body
				text_body[0] = text_body[0] % bch.btc(balance)
				text_body[2] = text_body[2] % bch.btc(unconfirmed)
				text_body[4] = text_body[4] % bch.btc(balance - unconfirmed)
				if config['currency'] == '':
					del(text_body[5])
					del(text_body[3])
					del(text_body[1])
				else:
					text_body[1] = text_body[1] % (config['currency'], bch.fiat(balance * rate))
					text_body[3] = text_body[3] % (config['currency'], bch.fiat(unconfirmed * rate))
					text_body[5] = text_body[5] % (config['currency'], bch.fiat((balance - unconfirmed) * rate))
				# Send the email
				sendmail.send(config, config['email'], report, '\n'.join(text_body))
			print(report)
		# Update balances
		addresses[addr] = balance
		for line in lines:
			if line.startswith('address=%s' % addr):
				lines[lines.index(line)] = 'address=%s,%s\n' % (addr, bch.btc(balance))
				break
	# Write updates to the config file
	with open(cfg_file, 'w') as f:
		f.writelines(lines)
	if config['frequency'] == 0:
		sys.exit(0)