diff --git a/.gitignore b/.gitignore index 13d1490..56cc628 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,7 @@ dmypy.json # Pyre type checker .pyre/ + +# Local data +data +config.py diff --git a/README.md b/README.md index 029025e..e6efafb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,43 @@ # tg-tipbot -Telegram tip bot \ No newline at end of file +Telegram tip bot. + +## Setup + +``` +# initialize new wallet and retain seed +docker run --rm -it --name wow-wallet-init \ + -v $(pwd)/data:/root \ + lalanza808/wownero \ + wownero-wallet-cli \ + --daemon-address https://node.suchwow.xyz:443 \ + --generate-new-wallet /root/wow \ + --password zzzzzz \ + +# setup rpc process +docker run --rm -d --name wow-wallet \ + -v $(pwd)/data:/root \ + -p 8888:8888 \ + lalanza808/wownero \ + wownero-wallet-rpc \ + --daemon-address https://node.suchwow.xyz:443 \ + --wallet-file /root/wow \ + --password zzzzzz \ + --rpc-bind-port 8888 \ + --rpc-bind-ip 0.0.0.0 \ + --confirm-external-bind \ + --rpc-login xxxx:yyyy \ + --log-file /root/rpc.log + +# install python dependencies +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +# setup secrets in config file outside of git +cp tipbot/config.example.py tipbot/config.py +vim !$ + +# run it +python3 tipbot/tipbot.py +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5dd1898 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +certifi==2020.6.20 +cffi==1.14.0 +chardet==3.0.4 +cryptography==2.9.2 +decorator==4.4.2 +idna==2.10 +peewee==3.13.3 +pycparser==2.20 +python-telegram-bot==12.8 +requests==2.24.0 +six==1.15.0 +tornado==6.0.4 +urllib3==1.25.9 diff --git a/tipbot/__init__.py b/tipbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tipbot/commands.py b/tipbot/commands.py new file mode 100644 index 0000000..01412dd --- /dev/null +++ b/tipbot/commands.py @@ -0,0 +1,250 @@ +import wownero +import config +import logging +import db +import six +from functools import wraps +from decimal import Decimal + + +def wallet_rpc_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + wallet = wownero.Wallet() + if not wallet.connected: + logging.error(f'Wallet RPC interface is not available: {args[0].message}') + args[0].message.reply_text('Wallet RPC interface is not available right now. Try again later.') + return False + return f(*args, **kwargs) + return decorated_function + +def registration_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + wallet = wownero.Wallet() + if not db.User.filter(telegram_id=args[0].effective_chat.id): + args[0].message.reply_text('You are not yet registered. Issue the /register command.') + return False + return f(*args, **kwargs) + return decorated_function + + +def help(update, context): + commands = list() + for i in all_commands: + pk = all_commands[i] + if not pk.get('admin', False): + commands.append('{example} - {help}'.format( + example=pk['example'], + help=pk['help'] + )) + update.message.reply_text('Here are the available commands for this bot:\n\n' + '\n\n'.join(commands)) + +@wallet_rpc_required +def register(update, context): + uid = update.effective_chat.id + un = update.effective_chat.username + if db.User.filter(telegram_id=uid): + update.message.reply_text('You are already registered.') + else: + wallet = wownero.Wallet() + try: + account_index = wallet.new_account(label=un) + except Exception as e: + logging.error(f'Unable to create a new account in wallet RPC: {e}. Debug: {update.message}') + update.message.reply_text('Unable to create a new account for you. Ask for help.') + return False + try: + u = db.User( + telegram_id=uid, + telegram_user=un, + account_index=account_index, + ) + u.save() + reply_text = [ + 'You have been registered and can now send and receive tips.', + 'Ask for /help to see all available bot commands.' + ] + update.message.reply_text(' '.join(reply_text)) + except Exception as e: + logging.error(f'Unable to register user in DB: {e}. Debug: {update.message}') + update.message.reply_text('Unable to create a new account for you. Ask for help.') + return False + +@wallet_rpc_required +@registration_required +def tip(update, context): + if len(context.args) < 2: + update.message.reply_text('Not enough arguments passed.') + return False + elif len(context.args) == 2: + message = "" + elif len(context.args) > 2: + message = context.args[2:] + + # validate target user + if context.args[0].startswith('@'): + target_un = context.args[0][1:] + else: + target_un = context.args[0] + + if target_un == update.effective_chat.username: + update.message.reply_text('You cannot tip yourself!') + return False + + if not db.User.filter(telegram_user=target_un): + reply_text = [ + 'That user has not registered and cannot receive tips yet.', + 'If they would like to receive a tip, have them /register with the bot.' + ] + update.message.reply_text(' '.join(reply_text)) + return False + + # validate amount + try: + amount = Decimal(context.args[1]) + except: + update.message.reply_text(f'Bad Wownero amount specified; "{context.args[1]}" is not a valid number.') + return False + + if amount < 1: + update.message.reply_text('Bad Wownero amount specified. Provide only positive integers or decimals greater than or equal to 1.') + return False + + tipper = db.User.get(telegram_id=update.effective_chat.id) + tipper_balances = wownero.Wallet().balances(account=tipper.account_index) + if amount > tipper_balances[1]: + update.message.reply_text(f'You do not have sufficient funds to send {amount} WOW. Check your /balance') + return False + + # get target user details + u = db.User.get(telegram_user=target_un) + address = wownero.Wallet().addresses(account=u.account_index)[0] + + # transfer funds to user + try: + tx = wownero.Wallet().transfer(dest_address=address, amount=amount, priority=2, account=u.account_index) + print(tx) + update.message.reply_text(f'@{update.effective_chat.username} has tipped @{target_un} {amount} WOW!') + except Exception as e: + logging.error(f'Unable to send transfer: {e}. Debug: {update.message}') + update.message.reply_text('Failed to send a tip. Ask for help.') + + + +@wallet_rpc_required +@registration_required +def send(update, context): + if len(context.args) < 2: + update.message.reply_text('Not enough arguments passed.') + return False + + # validate address + if len(context.args[0]) in [97, 107]: + address = context.args[0] + else: + update.message.reply_text('This does not look like a valid Wownero address. Try again.') + return False + + # validate amount + try: + amount = Decimal(context.args[1]) + except: + update.message.reply_text(f'Bad Wownero amount specified; "{context.args[1]}" is not a valid number.') + return False + + if amount < 1: + update.message.reply_text('Bad Wownero amount specified. Provide only positive integers or decimals greater than or equal to 1.') + return False + + tipper = db.User.get(telegram_id=update.effective_chat.id) + tipper_balances = wownero.Wallet().balances(account=tipper.account_index) + if amount > tipper_balances[1]: + update.message.reply_text(f'You do not have sufficient funds to send {amount} WOW. Check your /balance') + return False + + # transfer funds to given address + try: + tx = wownero.Wallet().transfer(dest_address=address, amount=amount, priority=2, account=u.account_index) + print(tx) + update.message.reply_text(f'@{update.effective_chat.username} has tipped @{target_un} {amount} WOW!') + except Exception as e: + logging.error(f'Unable to send transfer: {e}. Debug: {update.message}') + update.message.reply_text('Failed to send a tip. Ask for help.') + +@wallet_rpc_required +@registration_required +def balance(update, context): + u = db.User.get(telegram_id=update.effective_chat.id) + balances = wownero.Wallet().balances(account=u.account_index) + update.message.reply_text(f'Available balance for {update.effective_chat.username}: {balances[1]} ({balances[0]} locked)') + +@wallet_rpc_required +@registration_required +def deposit(update, context): + u = db.User.get(telegram_id=update.effective_chat.id) + address = wownero.Wallet().addresses(account=u.account_index)[0] + update.message.reply_text(f'Deposit address for {update.effective_chat.username}: {address}') + +@wallet_rpc_required +def debug(update, context): + if is_tg_admin(update.effective_chat.id): + # tx = wownero.Wallet().transfer( + # dest_address='WW2vmEGV68ZFeQWwPEJda3UcdWCPfWBnDK1Y6MB9Uojx9adBhCxfx9F51TomRjmD3z7Gyogie3mfVQEkRQjLxqbs1KMzaozDw', + # amount=Decimal(2), + # priority=2, + # account=0 + # ) + # update.message.reply_text(str(tx)) + # balances = wownero.Wallet().balances(account=0) + # addresses = wownero.Wallet().addresses(account=0) + # accounts = wownero.Wallet().accounts() + # a = [] + # for i in accounts: + # a.append(str(wownero.Wallet().balances(account=i)[1])) + update.message.reply_text("sup") + else: + update.message.reply_text('you cant do that.') + +def is_tg_admin(chat_id): + if chat_id == config.TG_ADMIN_ID: + return True + else: + return False + +all_commands = { + 'tip': { + 'func': tip, + 'example': '/tip ', + 'help': 'Tip a user in Wownero' + }, + 'send': { + 'func': send, + 'example': '/send
', + 'help': 'Send Wownero to a specified Wownero address' + }, + 'balance': { + 'func': balance, + 'example': '/balance', + 'help': 'Show your current balance' + }, + 'register': { + 'func': register, + 'example': '/register', + 'help': 'Register your Telegram user ID to this bot to begin sending and receiving tips', + }, + 'deposit': { + 'func': deposit, + 'example': '/deposit', + 'help': 'Show your Wownero wallet address for transferring funds to' + }, + 'help': { + 'func': help, + 'example': '/help', + 'help': 'Show available commands for the bot', + }, + 'debug': { + 'func': debug, + 'admin': True + } +} diff --git a/tipbot/config.example.py b/tipbot/config.example.py new file mode 100644 index 0000000..8143bbd --- /dev/null +++ b/tipbot/config.example.py @@ -0,0 +1,8 @@ +TG_TOKEN = 'tttttttttttt' +TG_ADMIN_ID = 0000000000 +WALLET_PROTO = 'http' +WALLET_HOST = 'localhost' +WALLET_PORT = 8888 +WALLET_USER = 'yyyy' +WALLET_PASS = 'xxxxxxxxx' +SQLITE_DB_PATH = '/tmp/db.sqlite' diff --git a/tipbot/db.py b/tipbot/db.py new file mode 100644 index 0000000..8fa67fe --- /dev/null +++ b/tipbot/db.py @@ -0,0 +1,17 @@ +from peewee import * +import config + + +db = SqliteDatabase(config.SQLITE_DB_PATH) + +class BaseModel(Model): + class Meta: + database = db + +class User(BaseModel): + telegram_id = IntegerField() + telegram_user = CharField() + account_index = IntegerField() + + +db.create_tables([User]) diff --git a/tipbot/tipbot.py b/tipbot/tipbot.py new file mode 100644 index 0000000..5d8ecf4 --- /dev/null +++ b/tipbot/tipbot.py @@ -0,0 +1,25 @@ +import logging +import commands +import wownero +import config +from os import environ +from telegram.ext import Updater, CommandHandler + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +if __name__ == '__main__': + token = config.TG_TOKEN + if token: + logging.info('Starting bot.') + updater = Updater(token=token, use_context=True) + dispatcher = updater.dispatcher + for cmd in commands.all_commands: + handler = CommandHandler(cmd, commands.all_commands[cmd]['func']) + dispatcher.add_handler(handler) + updater.start_polling() + else: + logging.error('No token provided. Quitting!') + exit(2) diff --git a/tipbot/wownero.py b/tipbot/wownero.py new file mode 100644 index 0000000..1c4a75b --- /dev/null +++ b/tipbot/wownero.py @@ -0,0 +1,116 @@ +import requests +import six +import json +import operator +import config +from decimal import Decimal + + +PICOWOW = Decimal('0.000000000001') + +class Wallet(object): + def __init__(self): + self.host = config.WALLET_HOST + self.port = config.WALLET_PORT + self.proto = config.WALLET_PROTO + self.username = config.WALLET_USER + self.password = config.WALLET_PASS + self.endpoint = '{}://{}:{}/json_rpc'.format( + self.proto, self.host, self.port + ) + self.auth = requests.auth.HTTPDigestAuth( + self.username, self.password + ) + + try: + r = self.height() + height = r['height'] + self.connected = True + except: + self.connected = False + + def make_wallet_rpc(self, method, params={}): + r = requests.get( + self.endpoint, + data=json.dumps({'method': method, 'params': params}), + auth=self.auth + ) + # print(r.status_code) + if 'error' in r.json(): + return r.json()['error'] + else: + return r.json()['result'] + + def height(self): + return self.make_wallet_rpc('get_height', {}) + + def spend_key(self): + return self.make_wallet_rpc('query_key', {'key_type': 'spend_key'})['key'] + + def view_key(self): + return self.make_wallet_rpc('query_key', {'key_type': 'view_key'})['key'] + + def seed(self): + return self.make_wallet_rpc('query_key', {'key_type': 'mnemonic'})['key'] + + def accounts(self): + accounts = [] + _accounts = self.make_wallet_rpc('get_accounts') + idx = 0 + self.master_address = _accounts['subaddress_accounts'][0]['base_address'] + for _acc in _accounts['subaddress_accounts']: + assert idx == _acc['account_index'] + accounts.append(_acc['account_index']) + idx += 1 + return accounts + + def new_account(self, label=None): + _account = self.make_wallet_rpc('create_account', {'label': label}) + return _account['account_index'] + + def addresses(self, account, addr_indices=None): + qdata = {'account_index': account} + if addr_indices: + qdata['address_index'] = addr_indices + _addresses = self.make_wallet_rpc('get_address', qdata) + addresses = [None] * (max(map(operator.itemgetter('address_index'), _addresses['addresses'])) + 1) + for _addr in _addresses['addresses']: + addresses[_addr['address_index']] = _addr['address'] + return addresses + + def new_address(self, account, label=None): + data = {'account_index': account, 'label': label} + _address = self.make_wallet_rpc('create_address', data) + return (_address['address_index'], _address['address']) + + def balances(self, account): + data = {'account_index': account} + _balance = self.make_wallet_rpc('getbalance', data) + return (from_atomic(_balance['balance']), from_atomic(_balance['unlocked_balance'])) + + def transfer(self, dest_address, amount, priority, account): + data = { + 'account_index': account, + 'destinations': [{'address': dest_address, 'amount': to_atomic(amount)}], + 'priority': priority, + 'unlock_time': 0, + 'get_tx_key': True, + 'get_tx_hex': True, + 'new_algorithm': True, + 'do_not_relay': False, + } + transfer = self.make_wallet_rpc('transfer', data) + return transfer + + +def to_atomic(amount): + if not isinstance(amount, (Decimal, float) + six.integer_types): + raise ValueError("Amount '{}' doesn't have numeric type. Only Decimal, int, long and " + "float (not recommended) are accepted as amounts.") + return int(amount * 10**12) + +def from_atomic(amount): + return (Decimal(amount) * PICOWOW).quantize(PICOWOW) + +def as_wownero(amount): + return Decimal(amount).quantize(PICOWOW)