commit b83b1ed61cfda14c09f2e596bb0db1a6a568d7ac Author: jwinterm Date: Fri Nov 10 08:00:49 2017 -0800 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2916b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# cointipbot +*.log* +pifkoin +conf diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..32ad1c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., [http://fsf.org/] + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e7f58d --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Reddit ALTcointip bot + +## Introduction + +For introduction to and use of ALTcointip bot, see __http://www.reddit.com/r/ALTcointip/wiki/index__ + +## Getting Started + +>**NEW: See [altcointip-chef](https://github.com/vindimy/altcointip-chef) for Chef-automated installation and configuration of ALTcointip bot.** + +### Python Dependencies + +The following Python libraries are necessary to run ALTcointip bot: + +* __jinja2__ (http://jinja.pocoo.org/) +* __pifkoin__ (https://github.com/dpifke/pifkoin) +* __praw__ (https://github.com/praw-dev/praw) +* __sqlalchemy__ (http://www.sqlalchemy.org/) +* __yaml__ (http://pyyaml.org/wiki/PyYAML) + +You can install `jinja2`, `praw`, `sqlalchemy`, and `yaml` using `pip` (Python Package Index tool) or a package manager in your OS. For `pifkoin`, you'll need to copy or symlink its "python" subdirectory to `src/ctb/pifkoin`. + +### Database + +Create a new MySQL database instance and run included SQL file [altcointip.sql](altcointip.sql) to create necessary tables. Create a MySQL user and grant it all privileges on the database. If you don't like to deal with command-line MySQL, use `phpMyAdmin`. + +### Coin Daemons + +Download one or more coin daemon executable. Create a configuration file for it in appropriate directory (such as `~/.mycoin/mycoin.conf` for Litecoin), specifying `rpcuser`, `rpcpassword`, `rpcport`, and `server=1`, then start the daemon. It will take some time for the daemon to download the blockchain, after which you should verify that it's accepting commands (such as `mycoind getinfo` and `mycoind listaccounts`). + +### Reddit Account + +You should create a dedicated Reddit account for your bot. Initially, Reddit will ask for CAPTCHA input when bot posts a comment or message. To remove CAPTCHA requirement, the bot account needs to accumulate positive karma. + +### Configuration + +Copy included set of configuration files [src/conf-sample/](src/conf-sample/) as `src/conf/` and edit `reddit.yml`, `db.yml`, `coins.yml`, and `regex.yml`, specifying necessary settings. + +Most configuration options are described inline in provided sample configuration files. + +### Running the Bot + +1. Ensure MySQL is running and accepting connections given configured username/password +1. Ensure each configured coin daemon is running and responding to commands +1. Ensure Reddit authenticates configured user. _Note that from new users Reddit will require CAPTCHA responses when posting and sending messages. You will be able to type in CAPTCHA responses when required._ +1. Execute `_start.sh` from [src](src/) directory. The command will not return for as long as the bot is running. + +Here's the first few lines of DEBUG-level console output during successful initialization. + + user@host:/opt/altcointip/altcointip/src$ ./_start.sh + INFO:cointipbot:CointipBot::init_logging(): -------------------- logging initialized -------------------- + DEBUG:cointipbot:CointipBot::connect_db(): connecting to database... + INFO:cointipbot:CointipBot::connect_db(): connected to database altcointip as altcointip + DEBUG:cointipbot:CtbCoin::__init__(): connecting to Peercoin... + DEBUG:bitcoin:Read 5 parameters from /opt/altcointip/coins/ppcoin/ppcoin.conf + DEBUG:bitcoin:Making HTTP connection to 127.0.0.1:19902 + INFO:cointipbot:CtbCoin::__init__():: connected to Peercoin + INFO:cointipbot:Setting tx fee of 0.010000 + DEBUG:bitcoin:Starting "settxfee" JSON-RPC request + DEBUG:bitcoin:Got 36 byte response from server in 4 ms + DEBUG:cointipbot:CtbCoin::__init__(): connecting to Primecoin... + DEBUG:bitcoin:Read 5 parameters from /opt/altcointip/coins/primecoin/primecoin.conf + DEBUG:bitcoin:Making HTTP connection to 127.0.0.1:18772 + INFO:cointipbot:CtbCoin::__init__():: connected to Primecoin + INFO:cointipbot:Setting tx fee of 0.010000 + DEBUG:bitcoin:Starting "settxfee" JSON-RPC request + DEBUG:bitcoin:Got 36 byte response from server in 1 ms + DEBUG:cointipbot:CtbCoin::__init__(): connecting to Megacoin... + DEBUG:bitcoin:Read 5 parameters from /opt/altcointip/coins/megacoin/megacoin.conf + DEBUG:bitcoin:Making HTTP connection to 127.0.0.1:17950 + INFO:cointipbot:CtbCoin::__init__():: connected to Megacoin + INFO:cointipbot:Setting tx fee of 0.010000 + DEBUG:bitcoin:Starting "settxfee" JSON-RPC request + DEBUG:bitcoin:Got 36 byte response from server in 1 ms + DEBUG:cointipbot:CtbCoin::__init__(): connecting to Litecoin... + DEBUG:bitcoin:Read 5 parameters from /opt/altcointip/coins/litecoin/litecoin.conf + DEBUG:bitcoin:Making HTTP connection to 127.0.0.1:19332 + INFO:cointipbot:CtbCoin::__init__():: connected to Litecoin + INFO:cointipbot:Setting tx fee of 0.020000 + DEBUG:bitcoin:Starting "settxfee" JSON-RPC request + DEBUG:bitcoin:Got 36 byte response from server in 2 ms + DEBUG:cointipbot:CtbCoin::__init__(): connecting to Namecoin... + DEBUG:bitcoin:Read 5 parameters from /opt/altcointip/coins/namecoin/namecoin.conf + DEBUG:bitcoin:Making HTTP connection to 127.0.0.1:18336 + INFO:cointipbot:CtbCoin::__init__():: connected to Namecoin + INFO:cointipbot:Setting tx fee of 0.010000 + DEBUG:bitcoin:Starting "settxfee" JSON-RPC request + DEBUG:bitcoin:Got 36 byte response from server in 1 ms + DEBUG:cointipbot:CtbCoin::__init__(): connecting to Bitcoin... + DEBUG:bitcoin:Read 5 parameters from /opt/altcointip/coins/bitcoin/bitcoin.conf + DEBUG:bitcoin:Making HTTP connection to 127.0.0.1:18332 + INFO:cointipbot:CtbCoin::__init__():: connected to Bitcoin + INFO:cointipbot:Setting tx fee of 0.000100 + DEBUG:bitcoin:Starting "settxfee" JSON-RPC request + DEBUG:bitcoin:Got 36 byte response from server in 1 ms + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange crypto-trade.com + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange www.bitstamp.net + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange bter.com + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange blockchain.info + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange campbx.com + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange vircurex.com + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange pubapi.cryptsy.com + DEBUG:cointipbot:CtbExchange::__init__(): initialized exchange btc-e.com + DEBUG:cointipbot:CointipBot::connect_reddit(): connecting to Reddit... + INFO:cointipbot:CointipBot::connect_reddit(): logged in to Reddit as ALTcointip + ... + +ALTcointip bot is configured by default to append INFO-level log messages to `logs/info.log`, and WARNING-level log messages to `logs/warning.log`, while DEBUG-level log messages are output to the console. + +### Cron: Backups + +Backups are very important! The last thing you want is losing user wallets or record of transactions in the databse. + +There are three simple backup scripts included that support backing up database, wallets, and configuration files to local directory and (optionally) to a remote host with `rsync`. Make sure to schedule regular backups with cron and test whether they are actually performed. Example cron configuration: + + 0 8,20 * * * cd /opt/altcointip/altcointip/src && python _backup_db.py ~/backups + 0 9,21 * * * cd /opt/altcointip/altcointip/src && python _backup_wallets.py ~/backups + 0 10 * * * cd /opt/altcointip/altcointip/src && python _backup_config.py ~/backups + +### Cron: Statistics + +ALTcointip bot can be configured to generate tipping statistics pages (overall and per-user) and publish them using subreddit's wiki. After you configure and enable statistics in configuration, add the following cron job to update the main statistics page periodically: + + 0 */3 * * * cd /opt/altcointip/altcointip/src && python _update_stats.py + +### What If I Want To Enable More Cryptocoins Later? + +If you want to add a new cryptocoin after you already have a few registered users, you need to retroactively create the new cryptocoin address for users who have already registered. See [src/_add_coin.py](src/_add_coin.py) for details. diff --git a/altcointip.sql b/altcointip.sql new file mode 100644 index 0000000..dd5d0bd --- /dev/null +++ b/altcointip.sql @@ -0,0 +1,42 @@ +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +SET time_zone = "+00:00"; + +CREATE TABLE IF NOT EXISTS `t_action` ( + `type` enum('givetip','withdraw','info','register','accept','decline','history','redeem','rates') NOT NULL, + `state` enum('completed','pending','failed','declined','expired') NOT NULL, + `created_utc` int(11) unsigned NOT NULL, + `from_user` varchar(30) NOT NULL, + `to_user` varchar(30) DEFAULT NULL, + `to_addr` varchar(34) DEFAULT NULL, + `coin_val` float unsigned DEFAULT NULL, + `fiat_val` float unsigned DEFAULT NULL, + `txid` varchar(64) DEFAULT NULL, + `coin` varchar(3) DEFAULT NULL, + `fiat` varchar(3) DEFAULT NULL, + `subreddit` varchar(30) DEFAULT NULL, + `msg_id` varchar(10) NOT NULL, + `msg_link` varchar(200) DEFAULT NULL, + PRIMARY KEY (`type`,`created_utc`,`msg_id`), + UNIQUE KEY `msg_id` (`msg_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `t_addrs` ( + `username` varchar(30) NOT NULL, + `coin` varchar(3) NOT NULL, + `address` varchar(34) NOT NULL, + PRIMARY KEY (`username`,`coin`), + UNIQUE KEY `address` (`address`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `t_users` ( + `username` varchar(30) NOT NULL, + `joindate` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `giftamount` float DEFAULT '0', + PRIMARY KEY (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `t_values` ( + `param0` varchar(64) NOT NULL, + `value0` int(11) NOT NULL DEFAULT '0', + PRIMARY KEY (`param0`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..e69de29 diff --git a/src/_add_coin.py b/src/_add_coin.py new file mode 100644 index 0000000..166b2ea --- /dev/null +++ b/src/_add_coin.py @@ -0,0 +1,25 @@ +# Here's how to add a new coin type to CointipBot + +# * Make sure CointipBot instance is NOT running +# * Install and run coin daemon, make sure it's synced with network +# * Configure and nable new coin in config.yml +# * Then run this script, specifying coin (such as "python _add_coin.py btc") +# * After this script has finished, you can reusme the tip bot normally + +import cointipbot, logging, sys +from ctb import ctb_coin, ctb_misc + +if not len(sys.argv) == 2: + print "Usage: %s COIN" % sys.argv[0] + print "(COIN refers to ctb.conf[COIN], a hash location in coins.yml)" + sys.exit(1) + +coin = sys.argv[1] + +logging.basicConfig() +lg = logging.getLogger('cointipbot') +lg.setLevel(logging.DEBUG) + +ctb = cointipbot.CointipBot(self_checks=False, init_reddit=False, init_coins=False, init_exchanges=False, init_db=True, init_logging=True) +ctb.coins[coin] = ctb_coin.CtbCoin(_conf=ctb.conf.coins[coin]) +ctb_misc.add_coin(coin, ctb.db, ctb.coins) diff --git a/src/_backup_config.py b/src/_backup_config.py new file mode 100644 index 0000000..62d1e17 --- /dev/null +++ b/src/_backup_config.py @@ -0,0 +1,37 @@ +# Simple script to back up ALTcointip conf/ dir + +import sys, os, datetime +from distutils.spawn import find_executable +import cointipbot + +if not len(sys.argv) in [2, 3] or not os.access(sys.argv[1], os.W_OK): + print "Usage: %s DIRECTORY [RSYNC-TO]" % sys.argv[0] + print "(DIRECTORY must be writeable, RSYNC-TO is optional location to RSYNC the file to)" + sys.exit(1) + +ctb = cointipbot.CointipBot(self_checks=False, init_reddit=False, init_coins=False, init_db=False, init_logging=False) + +if not find_executable('zip'): + print "zip executable not found, please install zip" + sys.exit(1) + +if hasattr(ctb.conf.misc.backup, 'encryptionpassphrase') and not find_executable('gpg'): + print "encryptionpassphrase is specified but gpg executable not found, please install gpg" + sys.exit(1) + +filename = "%s/conf_%s.zip" % (sys.argv[1], datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) + +print "Backing up to %s..." % filename +os.popen("zip -r %s conf/" % filename) + +try: + print "Encrypting..." + os.popen("gpg --batch --passphrase '%s' -c %s" % (ctb.conf.misc.backup.encryptionpassphrase, filename)) + os.popen("rm -f %s" % filename) + filename += '.gpg' +except AttributeError: + print "Not encrypting" + +if len(sys.argv) == 3: + print "Calling rsync..." + os.popen("rsync -urltv %s %s" % (filename, sys.argv[2])) diff --git a/src/_backup_db.py b/src/_backup_db.py new file mode 100644 index 0000000..2c70e47 --- /dev/null +++ b/src/_backup_db.py @@ -0,0 +1,37 @@ +# Simple script to back up ALTcointip database + +import sys, os, datetime +from distutils.spawn import find_executable +import cointipbot + +if not len(sys.argv) in [2, 3] or not os.access(sys.argv[1], os.W_OK): + print "Usage: %s DIRECTORY [RSYNC-TO]" % sys.argv[0] + print "(DIRECTORY must be writeable, RSYNC-TO is optional location to RSYNC the file to)" + sys.exit(1) + +ctb = cointipbot.CointipBot(self_checks=False, init_reddit=False, init_coins=False, init_db=True, init_logging=False) + +if not find_executable('gzip'): + print "gzip executable not found, please install gzip" + sys.exit(1) + +if hasattr(ctb.conf.misc.backup, 'encryptionpassphrase') and not find_executable('gpg'): + print "encryptionpassphrase is specified but gpg executable not found, please install gpg" + sys.exit(1) + +filename = "%s/%s_%s.sql.gz" % (sys.argv[1], ctb.conf.db.auth.dbname, datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) + +print "Backing up to %s..." % filename +os.popen("mysqldump -u %s -p%s -h %s -e --opt -c %s | gzip --best -c >%s" % (ctb.conf.db.auth.user, ctb.conf.db.auth.password, ctb.conf.db.auth.host, ctb.conf.db.auth.dbname, filename)) + +try: + print "Encrypting..." + os.popen("gpg --batch --passphrase '%s' -c %s" % (ctb.conf.misc.backup.encryptionpassphrase, filename)) + os.popen("rm -f %s" % filename) + filename += '.gpg' +except AttributeError: + print "Not encrypting" + +if len(sys.argv) == 3: + print "Calling rsync..." + os.popen("rsync -urltv %s %s" % (filename, sys.argv[2])) diff --git a/src/_backup_wallets.py b/src/_backup_wallets.py new file mode 100644 index 0000000..0d91368 --- /dev/null +++ b/src/_backup_wallets.py @@ -0,0 +1,46 @@ +# Simple script to back up active coin wallets + +import sys, os, datetime, logging +from distutils.spawn import find_executable +import cointipbot + +logging.basicConfig() +lg = logging.getLogger('cointipbot') +lg.setLevel(logging.DEBUG) + +if not len(sys.argv) in [2, 3] or not os.access(sys.argv[1], os.W_OK): + print "Usage: %s DIRECTORY [RSYNC-TO]" % sys.argv[0] + print "(DIRECTORY must be writeable, RSYNC-TO is optional location to RSYNC the file to)" + sys.exit(1) + +ctb = cointipbot.CointipBot(self_checks=False, init_reddit=False, init_coins=True, init_db=False, init_logging=False) + +if not find_executable('gzip'): + print "gzip executable not found, please install gzip" + sys.exit(1) + +if hasattr(ctb.conf.misc.backup, 'encryptionpassphrase') and not find_executable('gpg'): + print "encryptionpassphrase is specified but gpg executable not found, please install gpg" + sys.exit(1) + +for c in ctb.coins: + filename = "%s/wallet_%s_%s.dat" % (sys.argv[1], ctb.conf.coins[c].unit, datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")) + + print "Backing up %s wallet to %s..." % (ctb.conf.coins[c].name, filename) + ctb.coins[c].conn.backupwallet(filename) + + print "Compressing..." + os.popen("gzip --best %s" % filename) + filename += '.gz' + + try: + print "Encrypting..." + os.popen("gpg --batch --passphrase '%s' -c %s" % (ctb.conf.misc.backup.encryptionpassphrase, filename)) + os.popen("rm -f %s" % filename) + filename += '.gpg' + except AttributeError: + print "Not encrypting" + + if len(sys.argv) == 3: + print "Calling rsync..." + os.popen("rsync -urltv %s %s" % (filename, sys.argv[2])) diff --git a/src/_start.sh b/src/_start.sh new file mode 100755 index 0000000..8b6969f --- /dev/null +++ b/src/_start.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +user=$1 + +if [ ! -f ./cointipbot.py ] ; then + # Output help message and exit + echo "Usage: $0 [username]" + echo "if [username] is specified, script will be started as user [username]" + echo "$0 must be run from 'src' directory of ALTcointip" + exit 1 +fi + +if [ -z "$user" ] ; then + # Run as current user + python -c 'import cointipbot; cb=cointipbot.CointipBot(); cb.main()' +else + # Run as $user + sudo su - $user -c "cd `pwd` && python -c 'import cointipbot; cb=cointipbot.CointipBot(); cb.main()'" +fi diff --git a/src/_update_stats.py b/src/_update_stats.py new file mode 100644 index 0000000..91cd9e1 --- /dev/null +++ b/src/_update_stats.py @@ -0,0 +1,37 @@ +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +import cointipbot, logging +from ctb import ctb_stats + +logging.basicConfig() +lg = logging.getLogger('cointipbot') +lg.setLevel(logging.DEBUG) + +ctb = cointipbot.CointipBot(self_checks=False, init_reddit=True, init_coins=False, init_exchanges=False, init_db=True, init_logging=False) + +# Update stats page +result = ctb_stats.update_stats(ctb=ctb) +lg.debug(result) + +# Update tips page +result = ctb_stats.update_tips(ctb=ctb) +lg.debug(result) + +# This isn't needed because it happens during the tip processing +#result = ctb_stats.update_all_user_stats(ctb=ctb) +#lg.debug(result) diff --git a/src/cointipbot.py b/src/cointipbot.py new file mode 100644 index 0000000..76ce044 --- /dev/null +++ b/src/cointipbot.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +from ctb import ctb_action, ctb_coin, ctb_db, ctb_exchange, ctb_log, ctb_misc, ctb_user + +import gettext, locale, logging, praw, smtplib, sys, time, traceback, yaml +from email.mime.text import MIMEText +from jinja2 import Environment, PackageLoader + +from requests.exceptions import HTTPError, ConnectionError, Timeout +from praw.errors import ExceptionList, APIException, InvalidCaptcha, InvalidUser, RateLimitExceeded +from socket import timeout + +# Configure CointipBot logger +logging.basicConfig() +lg = logging.getLogger('cointipbot') + + +class CointipBot(object): + """ + Main class for cointip bot + """ + + conf = None + db = None + reddit = None + coins = {} + exchanges = {} + jenv = None + runtime = {'ev': {}, 'regex': []} + + def init_logging(self): + """ + Initialize logging handlers + """ + + handlers = {} + levels = ['warning', 'info', 'debug'] + lg = logging.getLogger('cointipbot') + bt = logging.getLogger('bitcoin') + + # Get handlers + handlers = {} + for l in levels: + if self.conf.logs.levels[l].enabled: + handlers[l] = logging.FileHandler(self.conf.logs.levels[l].filename, mode='a' if self.conf.logs.levels[l].append else 'w') + handlers[l].setFormatter(logging.Formatter(self.conf.logs.levels[l].format)) + + # Set handlers + for l in levels: + if handlers.has_key(l): + level = logging.WARNING if l == 'warning' else (logging.INFO if l == 'info' else logging.DEBUG) + handlers[l].addFilter(ctb_log.LevelFilter(level)) + lg.addHandler(handlers[l]) + bt.addHandler(handlers[l]) + + # Set default levels + lg.setLevel(logging.DEBUG) + bt.setLevel(logging.DEBUG) + + lg.info('CointipBot::init_logging(): -------------------- logging initialized --------------------') + return True + + def parse_config(self): + """ + Returns a Python object with CointipBot configuration + """ + lg.debug('CointipBot::parse_config(): parsing config files...') + + conf = {} + try: + prefix='./conf/' + for i in ['coins', 'db', 'exchanges', 'fiat', 'keywords', 'logs', 'misc', 'reddit', 'regex']: + lg.debug("CointipBot::parse_config(): reading %s%s.yml", prefix, i) + conf[i] = yaml.load(open(prefix+i+'.yml')) + except yaml.YAMLError as e: + lg.error("CointipBot::parse_config(): error reading config file: %s", e) + if hasattr(e, 'problem_mark'): + lg.error("CointipBot::parse_config(): error position: (line %s, column %s)", e.problem_mark.line+1, e.problem_mark.column+1) + sys.exit(1) + + lg.info('CointipBot::parse_config(): config files has been parsed') + return ctb_misc.DotDict(conf) + + def connect_db(self): + """ + Returns a database connection object + """ + lg.debug('CointipBot::connect_db(): connecting to database...') + + dsn = "mysql+mysqldb://%s:%s@%s:%s/%s?charset=utf8" % (self.conf.db.auth.user, self.conf.db.auth.password, self.conf.db.auth.host, self.conf.db.auth.port, self.conf.db.auth.dbname) + dbobj = ctb_db.CointipBotDatabase(dsn) + + try: + conn = dbobj.connect() + except Exception as e: + lg.error("CointipBot::connect_db(): error connecting to database: %s", e) + sys.exit(1) + + lg.info("CointipBot::connect_db(): connected to database %s as %s", self.conf.db.auth.dbname, self.conf.db.auth.user) + return conn + + def connect_reddit(self): + """ + Returns a praw connection object + """ + lg.debug('CointipBot::connect_reddit(): connecting to Reddit...') + + conn = praw.Reddit(user_agent = self.conf.reddit.auth.user) + conn.login(self.conf.reddit.auth.user, self.conf.reddit.auth.password) + + lg.info("CointipBot::connect_reddit(): logged in to Reddit as %s", self.conf.reddit.auth.user) + return conn + + def self_checks(self): + """ + Run self-checks before starting the bot + """ + + # Ensure bot is a registered user + b = ctb_user.CtbUser(name=self.conf.reddit.auth.user.lower(), ctb=self) + if not b.is_registered(): + b.register() + + # Ensure (total pending tips) < (CointipBot's balance) + for c in self.coins: + ctb_balance = b.get_balance(coin=c, kind='givetip') + pending_tips = float(0) + actions = ctb_action.get_actions(atype='givetip', state='pending', coin=c, ctb=self) + for a in actions: + pending_tips += a.coinval + if (ctb_balance - pending_tips) < -0.000001: + raise Exception("CointipBot::self_checks(): CointipBot's %s balance (%s) < total pending tips (%s)" % (c.upper(), ctb_balance, pending_tips)) + + # Ensure coin balances are positive + for c in self.coins: + b = float(self.coins[c].conn.getbalance()) + if b < 0: + raise Exception("CointipBot::self_checks(): negative balance of %s: %s" % (c, b)) + + # Ensure user accounts are intact and balances are not negative + sql = "SELECT username FROM t_users ORDER BY username" + for mysqlrow in self.db.execute(sql): + u = ctb_user.CtbUser(name=mysqlrow['username'], ctb=self) + if not u.is_registered(): + raise Exception("CointipBot::self_checks(): user %s is_registered() failed" % mysqlrow['username']) + # for c in vars(self.coins): + # if u.get_balance(coin=c, kind='givetip') < 0: + # raise Exception("CointipBot::self_checks(): user %s %s balance is negative" % (mysqlrow['username'], c)) + + return True + + def expire_pending_tips(self): + """ + Decline any pending tips that have reached expiration time limit + """ + + # Calculate timestamp + seconds = int(self.conf.misc.times.expire_pending_hours * 3600) + created_before = time.mktime(time.gmtime()) - seconds + counter = 0 + + # Get expired actions and decline them + for a in ctb_action.get_actions(atype='givetip', state='pending', created_utc='< ' + str(created_before), ctb=self): + a.expire() + counter += 1 + + # Done + return (counter > 0) + + def check_inbox(self): + """ + Evaluate new messages in inbox + """ + lg.debug('> CointipBot::check_inbox()') + + try: + + # Try to fetch some messages + messages = list(ctb_misc.praw_call(self.reddit.get_unread, limit=self.conf.reddit.scan.batch_limit)) + messages.reverse() + + # Process messages + for m in messages: + # Sometimes messages don't have an author (such as 'you are banned from' message) + if not m.author: + lg.info("CointipBot::check_inbox(): ignoring msg with no author") + ctb_misc.praw_call(m.mark_as_read) + continue + + lg.info("CointipBot::check_inbox(): %s from %s", "comment" if m.was_comment else "message", m.author.name) + + # Ignore duplicate messages (sometimes Reddit fails to mark messages as read) + if ctb_action.check_action(msg_id=m.id, ctb=self): + lg.warning("CointipBot::check_inbox(): duplicate action detected (msg.id %s), ignoring", m.id) + ctb_misc.praw_call(m.mark_as_read) + continue + + # Ignore self messages + if m.author and m.author.name.lower() == self.conf.reddit.auth.user.lower(): + lg.debug("CointipBot::check_inbox(): ignoring message from self") + ctb_misc.praw_call(m.mark_as_read) + continue + + # Ignore messages from banned users + if m.author and self.conf.reddit.banned_users: + lg.debug("CointipBot::check_inbox(): checking whether user '%s' is banned..." % m.author) + u = ctb_user.CtbUser(name = m.author.name, redditobj = m.author, ctb = self) + if u.banned: + lg.info("CointipBot::check_inbox(): ignoring banned user '%s'" % m.author) + ctb_misc.praw_call(m.mark_as_read) + continue + + action = None + if m.was_comment: + # Attempt to evaluate as comment / mention + action = ctb_action.eval_comment(m, self) + else: + # Attempt to evaluate as inbox message + action = ctb_action.eval_message(m, self) + + # Perform action, if found + if action: + lg.info("CointipBot::check_inbox(): %s from %s (m.id %s)", action.type, action.u_from.name, m.id) + lg.debug("CointipBot::check_inbox(): message body: <%s>", m.body) + action.do() + else: + lg.info("CointipBot::check_inbox(): no match") + if self.conf.reddit.messages.sorry and not m.subject in ['post reply', 'comment reply']: + user = ctb_user.CtbUser(name=m.author.name, redditobj=m.author, ctb=self) + tpl = self.jenv.get_template('didnt-understand.tpl') + msg = tpl.render(user_from=user.name, what='comment' if m.was_comment else 'message', source_link=m.permalink if hasattr(m, 'permalink') else None, ctb=self) + lg.debug("CointipBot::check_inbox(): %s", msg) + user.tell(subj='What?', msg=msg, msgobj=m if not m.was_comment else None) + + # Mark message as read + ctb_misc.praw_call(m.mark_as_read) + + except (HTTPError, ConnectionError, Timeout, timeout) as e: + lg.warning("CointipBot::check_inbox(): Reddit is down (%s), sleeping", e) + time.sleep(self.conf.misc.times.sleep_seconds) + pass + except RateLimitExceeded as e: + lg.warning("CointipBot::check_inbox(): rate limit exceeded, sleeping for %s seconds", e.sleep_time) + time.sleep(e.sleep_time) + time.sleep(1) + pass + except Exception as e: + lg.error("CointipBot::check_inbox(): %s", e) + raise + + lg.debug("< CointipBot::check_inbox() DONE") + return True + + def init_subreddits(self): + """ + Determine a list of subreddits and create a PRAW object + """ + lg.debug("> CointipBot::init_subreddits()") + + try: + + if not hasattr(self.conf.reddit, 'subreddits'): + my_reddits_list = None + my_reddits_string = None + + if hasattr(self.conf.reddit.scan, 'these_subreddits'): + # Subreddits are specified in conf.yml + my_reddits_list = list(self.conf.reddit.scan.these_subreddits) + + elif self.conf.reddit.scan.my_subreddits: + # Subreddits are subscribed to by bot user + my_reddits = ctb_misc.praw_call(self.reddit.get_my_subreddits, limit=None) + my_reddits_list = [] + for my_reddit in my_reddits: + my_reddits_list.append(my_reddit.display_name.lower()) + my_reddits_list.sort() + + else: + # No subreddits configured + lg.debug("< CointipBot::check_subreddits() DONE (no subreddits configured to scan)") + return False + + # Build subreddits string + my_reddits_string = "+".join(my_reddits_list) + + # Get multi-reddit PRAW object + lg.debug("CointipBot::check_subreddits(): multi-reddit string: %s", my_reddits_string) + self.conf.reddit.subreddits = ctb_misc.praw_call(self.reddit.get_subreddit, my_reddits_string) + + except Exception as e: + lg.error("CointipBot::check_subreddits(): coudln't get subreddits: %s", e) + raise + + lg.debug("< CointipBot::init_subreddits() DONE") + return True + + def check_subreddits(self): + """ + Evaluate new comments from configured subreddits + """ + lg.debug("> CointipBot::check_subreddits()") + + try: + # Process comments until old comment reached + + # Get last_processed_comment_time if necessary + if not hasattr(self.conf.reddit, 'last_processed_comment_time') or self.conf.reddit.last_processed_comment_time <= 0: + self.conf.reddit.last_processed_comment_time = ctb_misc.get_value(conn=self.db, param0='last_processed_comment_time') + updated_last_processed_time = 0 + + # Fetch comments from subreddits + my_comments = ctb_misc.praw_call(self.conf.reddit.subreddits.get_comments, limit=self.conf.reddit.scan.batch_limit) + + # Match each comment against regex + counter = 0 + for c in my_comments: + # Stop processing if old comment reached + #lg.debug("check_subreddits(): c.id %s from %s, %s <= %s", c.id, c.subreddit.display_name, c.created_utc, self.conf.reddit.last_processed_comment_time) + if c.created_utc <= self.conf.reddit.last_processed_comment_time: + lg.debug("CointipBot::check_subreddits(): old comment reached") + break + counter += 1 + if c.created_utc > updated_last_processed_time: + updated_last_processed_time = c.created_utc + + # Ignore duplicate comments (may happen when bot is restarted) + if ctb_action.check_action(msg_id=c.id, ctb=self): + lg.warning("CointipBot::check_inbox(): duplicate action detected (comment.id %s), ignoring", c.id) + continue + + # Ignore comments from banned users + if c.author and self.conf.reddit.banned_users: + lg.debug("CointipBot::check_subreddits(): checking whether user '%s' is banned..." % c.author) + u = ctb_user.CtbUser(name = c.author.name, redditobj = c.author, ctb = self) + if u.banned: + lg.info("CointipBot::check_subreddits(): ignoring banned user '%s'" % c.author) + continue + + # Attempt to evaluate comment + action = ctb_action.eval_comment(c, self) + + # Perform action, if found + if action: + lg.info("CointipBot::check_subreddits(): %s from %s (%s)", action.type, action.u_from.name, c.id) + lg.debug("CointipBot::check_subreddits(): comment body: <%s>", c.body) + action.do() + else: + lg.info("CointipBot::check_subreddits(): no match") + + lg.debug("CointipBot::check_subreddits(): %s comments processed", counter) + if counter >= self.conf.reddit.scan.batch_limit - 1: + lg.warning("CointipBot::check_subreddits(): conf.reddit.scan.batch_limit (%s) was not large enough to process all comments", self.conf.reddit.scan.batch_limit) + + except (HTTPError, RateLimitExceeded, timeout) as e: + lg.warning("CointipBot::check_subreddits(): Reddit is down (%s), sleeping", e) + time.sleep(self.conf.misc.times.sleep_seconds) + pass + except Exception as e: + lg.error("CointipBot::check_subreddits(): coudln't fetch comments: %s", e) + raise + + # Save updated last_processed_time value + if updated_last_processed_time > 0: + self.conf.reddit.last_processed_comment_time = updated_last_processed_time + ctb_misc.set_value(conn=self.db, param0='last_processed_comment_time', value0=self.conf.reddit.last_processed_comment_time) + + lg.debug("< CointipBot::check_subreddits() DONE") + return True + + def refresh_ev(self): + """ + Refresh coin/fiat exchange values using self.exchanges + """ + + # Return if rate has been checked in the past hour + seconds = int(1 * 3600) + if hasattr(self.conf.exchanges, 'last_refresh') and self.conf.exchanges.last_refresh + seconds > int(time.mktime(time.gmtime())): + lg.debug("< CointipBot::refresh_ev(): DONE (skipping)") + return + + # For each enabled coin... + for c in vars(self.conf.coins): + if self.conf.coins[c].enabled: + + # Get BTC/coin exchange rate + values = [] + result = 0.0 + + if not self.conf.coins[c].unit == 'btc': + # For each exchange that supports this coin... + for e in self.exchanges: + if self.exchanges[e].supports_pair(_name1=self.conf.coins[c].unit, _name2='btc'): + # Get ticker value from exchange + value = self.exchanges[e].get_ticker_value(_name1=self.conf.coins[c].unit, _name2='btc') + if value and float(value) > 0.0: + values.append(float(value)) + + # Result is average of all responses + if len(values) > 0: + result = sum(values) / float(len(values)) + + else: + # BTC/BTC rate is always 1 + result = 1.0 + + # Assign result to self.runtime['ev'] + if not self.runtime['ev'].has_key(c): + self.runtime['ev'][c] = {} + self.runtime['ev'][c]['btc'] = result + + # For each enabled fiat... + for f in vars(self.conf.fiat): + if self.conf.fiat[f].enabled: + + # Get fiat/BTC exchange rate + values = [] + result = 0.0 + + # For each exchange that supports this fiat... + for e in self.exchanges: + if self.exchanges[e].supports_pair(_name1='btc', _name2=self.conf.fiat[f].unit): + # Get ticker value from exchange + value = self.exchanges[e].get_ticker_value(_name1='btc', _name2=self.conf.fiat[f].unit) + if value and float(value) > 0.0: + values.append(float(value)) + + # Result is average of all responses + if len(values) > 0: + result = sum(values) / float(len(values)) + + # Assign result to self.runtime['ev'] + if not self.runtime['ev'].has_key('btc'): + self.runtime['ev']['btc'] = {} + self.runtime['ev']['btc'][f] = result + + lg.debug("CointipBot::refresh_ev(): %s", self.runtime['ev']) + + # Update last_refresh + self.conf.exchanges.last_refresh = int(time.mktime(time.gmtime())) + + def coin_value(self, _coin, _fiat): + """ + Quick method to return _fiat value of _coin + """ + try: + value = self.runtime['ev'][_coin]['btc'] * self.runtime['ev']['btc'][_fiat] + except KeyError as e: + lg.warning("CointipBot::coin_value(%s, %s): KeyError", _coin, _fiat) + value = 0.0 + return value + + def notify(self, _msg=None): + """ + Send _msg to configured destination + """ + + # Construct MIME message + msg = MIMEText(_msg) + msg['Subject'] = self.conf.misc.notify.subject + msg['From'] = self.conf.misc.notify.addr_from + msg['To'] = self.conf.misc.notify.addr_to + + # Send MIME message + server = smtplib.SMTP(self.conf.misc.notify.smtp_host) + if self.conf.misc.notify.smtp_tls: + server.starttls() + server.login(self.conf.misc.notify.smtp_username, self.conf.misc.notify.smtp_password) + server.sendmail(self.conf.misc.notify.addr_from, self.conf.misc.notify.addr_to, msg.as_string()) + server.quit() + + def __init__(self, self_checks=True, init_reddit=True, init_coins=True, init_exchanges=True, init_db=True, init_logging=True): + """ + Constructor. Parses configuration file and initializes bot. + """ + lg.info("CointipBot::__init__()...") + + # Configuration + self.conf = self.parse_config() + + # Logging + if init_logging: + self.init_logging() + + # Templating with jinja2 + self.jenv = Environment(trim_blocks=True, loader=PackageLoader('cointipbot', 'tpl/jinja2')) + + # Database + if init_db: + self.db = self.connect_db() + + # Coins + if init_coins: + for c in vars(self.conf.coins): + if self.conf.coins[c].enabled: + self.coins[c] = ctb_coin.CtbCoin(_conf=self.conf.coins[c]) + if not len(self.coins) > 0: + lg.error("CointipBot::__init__(): Error: please enable at least one type of coin") + sys.exit(1) + + # Exchanges + if init_exchanges: + for e in vars(self.conf.exchanges): + if self.conf.exchanges[e].enabled: + self.exchanges[e] = ctb_exchange.CtbExchange(_conf=self.conf.exchanges[e]) + if not len(self.exchanges) > 0: + lg.warning("Cointipbot::__init__(): Warning: no exchanges are enabled") + + # Reddit + if init_reddit: + self.reddit = self.connect_reddit() + self.init_subreddits() + # Regex for Reddit messages + ctb_action.init_regex(self) + + # Self-checks + if self_checks: + self.self_checks() + + lg.info("< CointipBot::__init__(): DONE, batch-limit = %s, sleep-seconds = %s", self.conf.reddit.scan.batch_limit, self.conf.misc.times.sleep_seconds) + + def __str__(self): + """ + Return string representation of self + """ + me = ". +""" + +import ctb_user, ctb_misc, ctb_stats + +import logging, praw, re, time +from random import randint + +lg = logging.getLogger('cointipbot') + +class CtbActionExc(Exception): + pass + +class CtbAction(object): + """ + Action class for cointip bot + """ + + type=None # 'accept', 'decline', 'history', 'info', 'register', 'givetip', 'withdraw', 'redeem', 'rates' + state=None # 'completed', 'pending', 'failed', 'declined' + txid=None # cryptocoin transaction id, a 64-char string, if applicable + + u_from=None # CtbUser instance + u_to=None # CtbUser instance, if applicable + addr_to=None # destination cryptocoin address of 'givetip' and 'withdraw' actions, if applicable + + coin=None # coin for this action (for example, 'ltc') + fiat=None # fiat for this action (for example, 'usd'), if applicable + coinval=None # coin value of 'givetip' and 'withdraw' actions + fiatval=None # fiat value of the 'givetip' or 'withdraw' action + keyword=None # keyword that's used instead of coinval/fiatval + + subreddit=None # subreddit that originated the action, if applicable + + msg=None # Reddit object pointing to originating message/comment + msg_id=None # + ctb=None # CointipBot instance + + + def __init__(self, atype=None, msg=None, msg_id=None, from_user=None, to_user=None, to_addr=None, coin=None, fiat=None, coin_val=None, fiat_val=None, keyword=None, subr=None, ctb=None): + """ + Initialize CtbAction object with given parameters and run basic checks + """ + lg.debug("> CtbAction::__init__(%s)", vars()) + + self.type = atype + + self.coin = coin.lower() if coin else None + self.fiat = fiat.lower() if fiat else None + self.coinval = coin_val + self.fiatval = fiat_val + self.keyword = keyword.lower() if keyword else None + + self.msg = msg + self.ctb = ctb + + self.msg_id = self.msg.id if self.msg else msg_id + + self.addr_to = to_addr + self.u_to = ctb_user.CtbUser(name=to_user, ctb=ctb) if to_user else None + self.u_from = ctb_user.CtbUser(name=msg.author.name, redditobj=msg.author, ctb=ctb) if (msg and hasattr(msg, 'author') and msg.author) else ctb_user.CtbUser(name=from_user, ctb=ctb) + self.subreddit = subr + + # Do some checks + if not self.type: + raise Exception("CtbAction::__init__(type=?): type not set") + if not self.ctb: + raise Exception("CtbAction::__init__(type=%s): no reference to CointipBot", self.type) + #if not self.msg: + # raise Exception("CtbAction::__init__(type=%s): no reference to Reddit message/comment", self.type) + if self.type in ['givetip', 'withdraw']: + if not (bool(self.u_to) ^ bool(self.addr_to)): + raise Exception("CtbAction::__init__(atype=%s, from_user=%s): u_to xor addr_to must be set" % (self.type, self.u_from.name)) + if not (bool(self.coin) or bool(self.fiat) or bool(self.keyword)): + raise Exception("CtbAction::__init__(atype=%s, from_user=%s): coin or fiat or keyword must be set" % (self.type, self.u_from.name)) + if not (bool(self.coinval) or bool(self.fiatval) or bool(self.keyword)): + raise Exception("CtbAction::__init__(atype=%s, from_user=%s): coinval or fiatval or keyword must be set" % (self.type, self.u_from.name)) + if (not self.coinval or not float(self.coinval) > 0.0) and (not self.fiatval or not float(self.fiatval) > 0.0) and (not self.keyword): + raise CtbActionExc("CtbAction::__init__(type=%s, from_user=%s, to_user=%s): no (coinval or fiatval or keyword) given" % (self.type, self.u_from, self.u_to)) + + # Convert coinval and fiat to float, if necesary + if self.coinval and type(self.coinval) == unicode and self.coinval.replace('.', '').isnumeric(): + self.coinval = float(self.coinval) + if self.fiatval and type(self.fiatval) == unicode and self.fiatval.replace('.', '').isnumeric(): + self.fiatval = float(self.fiatval) + + lg.debug("CtbAction::__init__(): %s", self) + + # Determine coinval or fiatval, if keyword is given instead of numeric value + if self.type in ['givetip', 'withdraw']: + + if self.keyword: + if not self.ctb.conf.keywords[self.keyword].for_coin and not self.fiat: + # If fiat-only, set fiat to 'usd' if missing + self.fiat = 'usd' + if not self.ctb.conf.keywords[self.keyword].for_coin and not self.fiatval: + # If fiat-only, set fiatval as coinval, and clear coinval + self.fiatval = self.coinval + self.coinval = None + if not self.coin and not self.fiat: + # If both coin and fiat missing, set fiat to 'usd' + self.fiat = 'usd' + + if self.keyword and self.fiat and not self.coin and not self.ctb.conf.keywords[self.keyword].for_fiat: + # If keyword is coin-only but only fiat is set, give up + raise CtbActionExc("CtbAction::__init__(type=%s): keyword is coin-only, but only fiat is set") + + if self.keyword and self.fiat and not ( type(self.fiatval) in [float, int] and self.fiatval > 0.0 ): + # Determine fiat value + lg.debug("CtbAction::__init__(): determining fiat value given '%s'", self.keyword) + val = self.ctb.conf.keywords[self.keyword].value + if type(val) == float: + self.fiatval = val + elif type(val) == str: + lg.debug("CtbAction::__init__(): evaluating '%s'", val) + self.fiatval = eval(val) + if not type(self.fiatval) == float: + raise CtbActionExc("CtbAction::__init__(atype=%s, from_user=%s): couldn't determine fiatval from keyword '%s' (not float)" % (self.type, self.u_from.name, self.keyword)) + else: + raise CtbActionExc("CtbAction::__init__(atype=%s, from_user=%s): couldn't determine fiatval from keyword '%s' (not float or str)" % (self.type, self.u_from.name, self.keyword)) + + elif self.keyword and self.coin and not ( type(self.coinval) in [float, int] and self.coinval > 0.0 ): + # Determine coin value + lg.debug("CtbAction::__init__(): determining coin value given '%s'", self.keyword) + val = self.ctb.conf.keywords[self.keyword].value + if type(val) == float: + self.coinval = val + elif type(val) == str: + lg.debug("CtbAction::__init__(): evaluating '%s'", val) + self.coinval = eval(val) + if not type(self.coinval) == float: + raise CtbActionExc("CtbAction::__init__(atype=%s, from_user=%s): couldn't determine coinval from keyword '%s' (not float)" % (self.type, self.u_from.name, self.keyword)) + else: + raise CtbActionExc("CtbAction::__init__(atype=%s, from_user=%s): couldn't determine coinval from keyword '%s' (not float or str)" % (self.type, self.u_from.name, self.keyword)) + + # By this point we should have a proper coinval or fiatval + if not type(self.coinval) in [float, int] and not type(self.fiatval) in [float, int]: + raise CtbActionExc("CtbAction::__init__(atype=%s, from_user=%s): coinval or fiatval isn't determined" % (self.type, self.u_from.name)) + + # Determine coin, if given only fiat, using exchange rates + if self.type in ['givetip']: + if self.fiat and not self.coin: + lg.debug("CtbAction::__init__(atype=%s, from_user=%s): determining coin..." % (self.type, self.u_from.name)) + if not self.u_from.is_registered(): + # Can't proceed, abort + raise CtbActionExc("CtbAction::__init__(): can't determine coin for un-registered user %s", self.u_from.name) + # Choose a coin based on from_user's available balance (pick first one that can satisfy the amount) + cc = self.ctb.conf.coins + for c in sorted(self.ctb.coins): + lg.debug("CtbAction::__init__(atype=%s, from_user=%s): considering %s" % (self.type, self.u_from.name, c)) + # First, check if we have a ticker value for this coin and fiat + if not self.ctb.coin_value(cc[c].unit, self.fiat) > 0.0: + continue + # Compare available and needed coin balances + coin_balance_avail = self.u_from.get_balance(coin=cc[c].unit, kind='givetip') + coin_balance_need = self.fiatval / self.ctb.coin_value(cc[c].unit, self.fiat) + if coin_balance_avail > coin_balance_need or abs(coin_balance_avail - coin_balance_need) < 0.000001: + # Found coin with enough balance + self.coin = cc[c].unit + break + if not self.coin: + # Couldn't deteremine coin, abort + raise CtbActionExc("CtbAction::__init__(): can't determine coin for user %s" % self.u_from.name) + + # Calculate fiat or coin value with exchange rates + if self.type in ['givetip', 'withdraw']: + if not self.fiat: + # Set fiat to 'usd' if not specified + self.fiat = 'usd' + if not self.fiatval: + # Determine fiat value + if self.ctb.coin_value(self.ctb.conf.coins[self.coin].unit, self.fiat) <= 0.0: + raise CtbActionExc("CtbAction::__init__(): coin_value returned 0") + self.fiatval = self.coinval * self.ctb.coin_value(self.ctb.conf.coins[self.coin].unit, self.fiat) + elif not self.coinval: + # Determine coin value + if self.ctb.coin_value(self.ctb.conf.coins[self.coin].unit, self.fiat) <= 0.0: + raise CtbActionExc("CtbAction::__init__(): coin_value returned 0") + self.coinval = self.fiatval / self.ctb.coin_value(self.ctb.conf.coins[self.coin].unit, self.fiat) + + # Final check to make sure coin value is determined + if self.type in ['givetip', 'withdraw']: + if not self.coinval or not type(self.coinval) in [float, int]: + raise CtbActionExc("CtbAction::__init__(): couldn't determine coin value, giving up. CtbAction: <%s>", self) + + lg.debug("< CtbAction::__init__(atype=%s, from_user=%s) DONE", self.type, self.u_from.name) + + def __str__(self): + """"" + Return string representation of self + """ + me = "" + me = me % (self.type, self.msg.body if self.msg else '', self.u_from, self.u_to, self.addr_to, self.coin, self.fiat, self.coinval, self.fiatval, self.subreddit) + return me + + def update(self, state=None): + """ + Update action state in database + """ + lg.debug("> CtbAction::update(%s)", state) + + if not state: + raise Exception("CtbAction::update(): state is null") + if not self.type or not self.msg_id: + raise Exception("CtbAction::update(): type or msg_id missing") + + conn = self.ctb.db + sql = "UPDATE t_action SET state=%s WHERE type=%s AND msg_id=%s" + + try: + mysqlexec = conn.execute(sql, (state, self.type, self.msg_id)) + if mysqlexec.rowcount <= 0: + raise Exception("query didn't affect any rows") + except Exception as e: + lg.error("CtbAction::update(%s): error executing query <%s>: %s", state, sql % (state, self.type, self.msg_id)) + raise + + lg.debug("< CtbAction::update() DONE") + return True + + def save(self, state=None): + """ + Save action to database + """ + lg.debug("> CtbAction::save(%s)", state) + + # Make sure no negative values exist + if self.coinval < 0.0: + self.coinval = 0.0 + if self.fiatval < 0.0: + self.fiatval = 0.0 + + conn = self.ctb.db + sql = "INSERT INTO t_action (type, state, created_utc, from_user, to_user, to_addr, coin_val, fiat_val, txid, coin, fiat, subreddit, msg_id, msg_link)" + sql += " values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)" + + try: + mysqlexec = conn.execute(sql, + (self.type, + state, + self.msg.created_utc, + self.u_from.name.lower(), + self.u_to.name.lower() if self.u_to else None, + self.addr_to, + self.coinval, + self.fiatval, + self.txid, + self.coin, + self.fiat, + self.subreddit, + self.msg.id, + self.msg.permalink if hasattr(self.msg, 'permalink') else None)) + if mysqlexec.rowcount <= 0: + raise Exception("query didn't affect any rows") + except Exception as e: + lg.error("CtbAction::save(%s): error executing query <%s>: %s", state, sql % ( + self.type, + state, + self.msg.created_utc, + self.u_from.name.lower(), + self.u_to.name.lower() if self.u_to else None, + self.addr_to, + self.coinval, + self.fiatval, + self.txid, + self.coin, + self.fiat, + self.subreddit, + self.msg.id, + self.msg.permalink if hasattr(self.msg, 'permalink') else None), e) + raise + + lg.debug("< CtbAction::save() DONE") + return True + + def do(self): + """ + Call appropriate function depending on action type + """ + lg.debug("> CtbAction::do()") + + if not self.ctb.conf.regex.actions[self.type].enabled: + msg = self.ctb.jenv.get_template('command-disabled.tpl').render(a=self, ctb=self.ctb) + lg.info("CtbAction::do(): action %s is disabled", self.type) + ctb_misc.praw_call(self.msg.reply, msg) + return False + + if self.type == 'accept': + return self.accept() + + if self.type == 'decline': + return self.decline() + + if self.type == 'givetip': + result = self.givetip() + ctb_stats.update_user_stats(ctb=self.ctb, username=self.u_from.name) + if self.u_to: + ctb_stats.update_user_stats(ctb=self.ctb, username=self.u_to.name) + return result + + if self.type == 'history': + return self.history() + + if self.type == 'info': + return self.info() + + if self.type == 'register': + return self.register() + + if self.type == 'withdraw': + return self.givetip() + + if self.type == 'redeem': + return self.redeem() + + if self.type == 'rates': + return self.rates() + + lg.debug("< CtbAction::do() DONE") + return None + + def history(self): + """ + Provide user with transaction history + """ + lg.debug("> CtbAction::history()") + + # Generate history array + history = [] + sql_history = self.ctb.conf.db.sql.userhistory.sql + limit = int(self.ctb.conf.db.sql.userhistory.limit) + + mysqlexec = self.ctb.db.execute(sql_history, (self.u_from.name.lower(), self.u_from.name.lower(), limit)) + for m in mysqlexec: + history_entry = [] + for k in mysqlexec.keys(): + history_entry.append(ctb_stats.format_value(m, k, self.u_from.name.lower(), self.ctb, compact=True)) + history.append(history_entry) + + # Send message to user + msg = self.ctb.jenv.get_template('history.tpl').render(history=history, keys=mysqlexec.keys(), limit=limit, a=self, ctb=self.ctb) + lg.debug("CtbAction::history(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + + # Save as completed + self.save('completed') + + lg.debug("< CtbAction::history() DONE") + return True + + def accept(self): + """ + Accept pending tip + """ + lg.debug("> CtbAction::accept()") + + # Register as new user if necessary + if not self.u_from.is_registered(): + if not self.u_from.register(): + lg.warning("CtbAction::accept(): self.u_from.register() failed") + self.save('failed') + return False + + # Get pending actions + actions = get_actions(atype='givetip', to_user=self.u_from.name, state='pending', ctb=self.ctb) + if actions: + + # Accept each action + for a in actions: + a.givetip(is_pending=True) + # Update u_from (tip action) stats + ctb_stats.update_user_stats(ctb=a.ctb, username=a.u_from.name) + # Update u_from (accept action) stats + ctb_stats.update_user_stats(ctb=a.ctb, username=self.u_from.name) + # Save this action + self.save('completed') + + else: + + # No pending actions found, reply with error message + msg = self.ctb.jenv.get_template('no-pending-tips.tpl').render(user_from=self.u_from.name, a=self, ctb=self.ctb) + lg.debug("CtbAction::accept(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + # Save this action + self.save('failed') + + lg.debug("< CtbAction::accept() DONE") + return True + + def decline(self): + """ + Decline pending tips + """ + lg.debug("> CtbAction::decline()") + + actions = get_actions(atype='givetip', to_user=self.u_from.name, state='pending', ctb=self.ctb) + if actions: + for a in actions: + # Move coins back into a.u_from account + lg.info("CtbAction::decline(): moving %.9f %s from %s to %s", a.coinval, a.coin.upper(), self.ctb.conf.reddit.auth.user, a.u_from.name) + if not self.ctb.coins[a.coin].sendtouser(_userfrom=self.ctb.conf.reddit.auth.user, _userto=a.u_from.name, _amount=a.coinval): + raise Exception("CtbAction::decline(): failed to sendtouser()") + + # Update transaction as declined + a.update('declined') + + # Update u_from (tip action) stats + ctb_stats.update_user_stats(ctb=a.ctb, username=a.u_from.name) + + # Respond to tip comment + msg = self.ctb.jenv.get_template('confirmation.tpl').render(title='Declined', a=a, ctb=a.ctb, source_link=a.msg.permalink if a.msg else None) + lg.debug("CtbAction::decline(): " + msg) + if self.ctb.conf.reddit.messages.declined: + if not ctb_misc.praw_call(a.msg.reply, msg): + a.u_from.tell(subj="+tip declined", msg=msg) + else: + a.u_from.tell(subj="+tip declined", msg=msg) + + # Update u_from (decline action) stats + ctb_stats.update_user_stats(ctb=a.ctb, username=self.u_from.name) + + # Notify self.u_from + msg = self.ctb.jenv.get_template('pending-tips-declined.tpl').render(user_from=self.u_from.name, ctb=self.ctb) + lg.debug("CtbAction::decline(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + + # Save action to database + self.save('completed') + + else: + + msg = self.ctb.jenv.get_template('no-pending-tips.tpl').render(user_from=self.u_from.name, ctb=self.ctb) + lg.debug("CtbAction::decline(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + + # Save action to database + self.save('failed') + + lg.debug("< CtbAction::decline() DONE") + return True + + def expire(self): + """ + Expire a pending tip + """ + lg.debug("> CtbAction::expire()") + + # Move coins back into self.u_from account + lg.info("CtbAction::expire(): moving %.9f %s from %s to %s", self.coinval, self.coin.upper(), self.ctb.conf.reddit.auth.user, self.u_from.name) + if not self.ctb.coins[self.coin].sendtouser(_userfrom=self.ctb.conf.reddit.auth.user, _userto=self.u_from.name, _amount=self.coinval): + raise Exception("CtbAction::expire(): sendtouser() failed") + + # Update transaction as expired + self.update('expired') + + # Update user stats + ctb_stats.update_user_stats(ctb=self.ctb, username=self.u_from.name) + ctb_stats.update_user_stats(ctb=self.ctb, username=self.u_to.name) + + # Respond to tip comment + msg = self.ctb.jenv.get_template('confirmation.tpl').render(title='Expired', a=self, ctb=self.ctb, source_link=self.msg.permalink if self.msg else None) + lg.debug("CtbAction::expire(): " + msg) + if self.ctb.conf.reddit.messages.expired: + if not ctb_misc.praw_call(self.msg.reply, msg): + self.u_from.tell(subj="+tip expired", msg=msg) + else: + self.u_from.tell(subj="+tip expired", msg=msg) + + lg.debug("< CtbAction::expire() DONE") + return True + + def validate(self, is_pending=False): + """ + Validate an action + """ + lg.debug("> CtbAction::validate()") + + if self.type in ['givetip', 'withdraw']: + # Check if u_from has registered + if not self.u_from.is_registered(): + msg = self.ctb.jenv.get_template('not-registered.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): %s", msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + + if self.u_to and not self.u_to.is_on_reddit(): + msg = self.ctb.jenv.get_template('not-on-reddit.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): %s", msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + + # Verify that coin type is set + if not self.coin: + msg = self.ctb.jenv.get_template('no-coin-balances.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): %s", msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + + # Verify that u_from has coin address + if not self.u_from.get_addr(coin=self.coin): + lg.error("CtbAction::validate(): user %s doesn't have %s address", self.u_from.name, self.coin.upper()) + self.save('failed') if not is_pending else self.update('failed') + raise Exception + + # Verify minimum transaction size + txkind = 'givetip' if self.u_to else 'withdraw' + if self.coinval < self.ctb.conf.coins[self.coin].txmin[txkind]: + msg = self.ctb.jenv.get_template('tip-below-minimum.tpl').render(min_value=self.ctb.conf.coins[self.coin].txmin[txkind], a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): " + msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + + # Verify balance (unless it's a pending transaction being processed, in which case coins have been already moved to pending acct) + if self.u_to and not is_pending: + # Tip to user (requires less confirmations) + balance_avail = self.u_from.get_balance(coin=self.coin, kind='givetip') + if not ( balance_avail > self.coinval or abs(balance_avail - self.coinval) < 0.000001 ): + msg = self.ctb.jenv.get_template('tip-low-balance.tpl').render(balance=balance_avail, action_name='tip', a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): " + msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + elif self.addr_to: + # Tip/withdrawal to address (requires more confirmations) + balance_avail = self.u_from.get_balance(coin=self.coin, kind='withdraw') + balance_need = self.coinval + # Add mandatory network transaction fee + balance_need += self.ctb.conf.coins[self.coin].txfee + if not ( balance_avail > balance_need or abs(balance_avail - balance_need) < 0.000001 ): + msg = self.ctb.jenv.get_template('tip-low-balance.tpl').render(balance=balance_avail, action_name='withdraw', a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): " + msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + + # Check if u_to has any pending coin tips from u_from + if self.u_to and not is_pending: + if check_action(atype='givetip', state='pending', to_user=self.u_to.name, from_user=self.u_from.name, coin=self.coin, ctb=self.ctb): + # Send notice to u_from + msg = self.ctb.jenv.get_template('tip-already-pending.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): " + msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + + # Check if u_to has registered, if applicable + if self.u_to and not self.u_to.is_registered(): + # u_to not registered: + # - move tip into pending account + # - save action as 'pending' + # - notify u_to to accept tip + + # Move coins into pending account + minconf = self.ctb.coins[self.coin].conf.minconf.givetip + lg.info("CtbAction::validate(): moving %.9f %s from %s to %s (minconf=%s)...", self.coinval, self.coin.upper(), self.u_from.name, self.ctb.conf.reddit.auth.user, minconf) + if not self.ctb.coins[self.coin].sendtouser(_userfrom=self.u_from.name, _userto=self.ctb.conf.reddit.auth.user, _amount=self.coinval, _minconf=minconf): + raise Exception("CtbAction::validate(): sendtouser() failed") + + # Save action as pending + self.save('pending') + + # Respond to tip comment + msg = self.ctb.jenv.get_template('confirmation.tpl').render(title='Verified', a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): " + msg) + if self.ctb.conf.reddit.messages.verified: + if not ctb_misc.praw_call(self.msg.reply, msg): + self.u_from.tell(subj="+tip pending +accept", msg=msg) + else: + self.u_from.tell(subj="+tip pending +accept", msg=msg) + + # Send notice to u_to + msg = self.ctb.jenv.get_template('tip-incoming.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): %s", msg) + self.u_to.tell(subj="+tip pending", msg=msg) + + # Action saved as 'pending', return false to avoid processing it further + return False + + # Validate addr_to, if applicable + if self.addr_to: + if not self.ctb.coins[self.coin].validateaddr(_addr=self.addr_to): + msg = self.ctb.jenv.get_template('address-invalid.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::validate(): " + msg) + self.u_from.tell(subj="+tip failed", msg=msg) + self.save('failed') if not is_pending else self.update('failed') + return False + + # Action is valid + lg.debug("< CtbAction::validate() DONE") + return True + + def givetip(self, is_pending=False): + """ + Initiate tip + """ + lg.debug("> CtbAction::givetip()") + + # Check if action has been processed + if check_action(atype=self.type, msg_id=self.msg_id, ctb=self.ctb, is_pending=is_pending): + # Found action in database, returning + lg.warning("CtbAction::givetipt(): duplicate action %s (msg.id %s), ignoring", self.type, self.msg.id) + return False + + # Validate action + if not self.validate(is_pending=is_pending): + # Couldn't validate action, returning + return False + + if self.u_to: + # Process tip to user + + res = False + if is_pending: + # This is accept() of pending transaction, so move coins from pending account to receiver + lg.info("CtbAction::givetip(): moving %.9f %s from %s to %s...", self.coinval, self.coin.upper(), self.ctb.conf.reddit.auth.user, self.u_to.name) + res = self.ctb.coins[self.coin].sendtouser(_userfrom=self.ctb.conf.reddit.auth.user, _userto=self.u_to.name, _amount=self.coinval) + else: + # This is not accept() of pending transaction, so move coins from tipper to receiver + lg.info("CtbAction::givetip(): moving %.9f %s from %s to %s...", self.coinval, self.coin.upper(), self.u_from.name, self.u_to.name) + res = self.ctb.coins[self.coin].sendtouser(_userfrom=self.u_from.name, _userto=self.u_to.name, _amount=self.coinval) + + if not res: + # Tx failed + # Save/update action as failed + self.save('failed') if not is_pending else self.update('failed') + + # Send notice to u_from + msg = self.ctb.jenv.get_template('tip-went-wrong.tpl').render(a=self, ctb=self.ctb) + self.u_from.tell(subj="+tip failed", msg=msg) + + raise Exception("CtbAction::givetip(): sendtouser() failed") + + # Transaction succeeded + self.save('completed') if not is_pending else self.update('completed') + + # Send confirmation to u_to + msg = self.ctb.jenv.get_template('tip-received.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::givetip(): " + msg) + self.u_to.tell(subj="+tip received", msg=msg) + + # This is not accept() of pending transaction, so post verification comment + if not is_pending: + msg = self.ctb.jenv.get_template('confirmation.tpl').render(title='Verified', a=self, ctb=self.ctb) + lg.debug("CtbAction::givetip(): " + msg) + if self.ctb.conf.reddit.messages.verified: + if not ctb_misc.praw_call(self.msg.reply, msg): + self.u_from.tell(subj="+tip succeeded", msg=msg) + else: + self.u_from.tell(subj="+tip succeeded", msg=msg) + + lg.debug("< CtbAction::givetip() DONE") + return True + + elif self.addr_to: + # Process tip to address + try: + lg.info("CtbAction::givetip(): sending %.9f %s to %s...", self.coinval, self.coin, self.addr_to) + self.txid = self.ctb.coins[self.coin].sendtoaddr(_userfrom=self.u_from.name, _addrto=self.addr_to, _amount=self.coinval) + + except Exception as e: + + # Transaction failed + self.save('failed') if not is_pending else self.update('failed') + lg.error("CtbAction::givetip(): sendtoaddr() failed") + + # Send notice to u_from + msg = self.ctb.jenv.get_template('tip-went-wrong.tpl').render(a=self, ctb=self.ctb) + self.u_from.tell(subj="+tip failed", msg=msg) + + raise + + # Transaction succeeded + self.save('completed') if not is_pending else self.update('completed') + + # Post verification comment + msg = self.ctb.jenv.get_template('confirmation.tpl').render(title='Verified', a=self, ctb=self.ctb) + lg.debug("CtbAction::givetip(): " + msg) + if self.ctb.conf.reddit.messages.verified: + if not ctb_misc.praw_call(self.msg.reply, msg): + self.u_from.tell(subj="+tip succeeded", msg=msg) + else: + self.u_from.tell(subj="+tip succeeded", msg=msg) + + lg.debug("< CtbAction::givetip() DONE") + return True + + lg.debug("< CtbAction::givetip() DONE") + return None + + def info(self): + """ + Send user info about account + """ + lg.debug("> CtbAction::info()") + + # Check if user exists + if not self.u_from.is_registered(): + msg = self.ctb.jenv.get_template('not-registered.tpl').render(a=self, ctb=self.ctb) + self.u_from.tell(subj="+info failed", msg=msg) + return False + + # Info array to pass to template + info = [] + + # Get coin balances + for c in sorted(self.ctb.coins): + coininfo = ctb_misc.DotDict({}) + coininfo.coin = c + try: + # Get tip balance + coininfo.balance = self.ctb.coins[c].getbalance(_user=self.u_from.name, _minconf=self.ctb.conf.coins[c].minconf.givetip) + info.append(coininfo) + except Exception as e: + lg.error("CtbAction::info(%s): error retrieving %s coininfo: %s", self.u_from.name, c, e) + raise + + # Get fiat balances + fiat_total = 0.0 + for i in info: + i.fiat_symbol = self.ctb.conf.fiat.usd.symbol + if self.ctb.coin_value(self.ctb.conf.coins[i.coin].unit, 'usd') > 0.0: + i.fiat_balance = i.balance * self.ctb.coin_value(self.ctb.conf.coins[i.coin].unit, 'usd') + fiat_total += i.fiat_balance + + # Get coin addresses from MySQL + for i in info: + sql = "SELECT address FROM t_addrs WHERE username = '%s' AND coin = '%s'" % (self.u_from.name.lower(), i.coin) + mysqlrow = self.ctb.db.execute(sql).fetchone() + if not mysqlrow: + raise Exception("CtbAction::info(%s): no result from <%s>" % (self.u_from.name, sql)) + i.address = mysqlrow['address'] + + # Format and send message + msg = self.ctb.jenv.get_template('info.tpl').render(info=info, fiat_symbol=self.ctb.conf.fiat.usd.symbol, fiat_total=fiat_total, a=self, ctb=self.ctb) + ctb_misc.praw_call(self.msg.reply, msg) + + # Save action to database + self.save('completed') + + lg.debug("< CtbAction::info() DONE") + return True + + def register(self): + """ + Register a new user + """ + lg.debug("> CtbAction::register()") + + # If user exists, do nothing + if self.u_from.is_registered(): + lg.debug("CtbAction::register(%s): user already exists", self.u_from.name) + msg = self.ctb.jenv.get_template('already-registered.tpl').render(a=self, ctb=self.ctb) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('failed') + return False + + result = self.u_from.register() + + # Save action to database + if result: + self.save('completed') + else: + self.save('failed') + + # Send welcome message to user + if result: + msg = self.ctb.jenv.get_template('welcome.tpl').render(a=self, ctb=self.ctb) + ctb_misc.praw_call(self.msg.reply, msg) + + lg.debug("< CtbAction::register() DONE") + return result + + def redeem(self): + """ + Redeem karma for coins + """ + lg.debug("> CtbAction::redeem()") + + # Check if user is registered + if not self.u_from.is_registered(): + msg = self.ctb.jenv.get_template('not-registered.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::redeem(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('failed') + return False + + # Check if this user has redeemed karma in the past + has_redeemed = False + if self.ctb.conf.reddit.redeem.multicoin: + # Check if self.coin has been redeemed + has_redeemed = check_action(atype='redeem', from_user=self.u_from.name, state='completed', coin=self.coin, ctb=self.ctb) + else: + # Check if any coin has been redeemed + has_redeemed = check_action(atype='redeem', from_user=self.u_from.name, state='completed', ctb=self.ctb) + if has_redeemed: + msg = self.ctb.jenv.get_template('redeem-already-done.tpl').render(coin=self.ctb.conf.coins[self.coin].name if self.ctb.conf.reddit.redeem.multicoin else None, a=self, ctb=self.ctb) + lg.debug("CtbAction::redeem(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('failed') + return False + + # Check if this user has > minimum karma + user_karma = int(self.u_from.prawobj.link_karma) + int(self.u_from.prawobj.comment_karma) + if user_karma < self.ctb.conf.reddit.redeem.min_karma: + msg = self.ctb.jenv.get_template('redeem-low-karma.tpl').render(user_karma=user_karma, a=self, ctb=self.ctb) + lg.debug("CtbAction::redeem(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('failed') + return False + + # Determine amount + self.fiat = self.ctb.conf.reddit.redeem.unit + self.coinval, self.fiatval = self.u_from.get_redeem_amount(coin=self.coin, fiat=self.fiat) + + # Check if coinval and fiatval are valid + if not self.coinval or not self.fiatval or not self.coinval > 0.0 or not self.fiatval > 0.0: + msg = self.ctb.jenv.get_template('redeem-cant-compute.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::redeem(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('failed') + return False + + # Check if redeem account has enough balance + funds = self.ctb.coins[self.coin].getbalance(_user=self.ctb.conf.reddit.redeem.account, _minconf=1) + if self.coinval > funds or abs(self.coinval - funds) < 0.000001: + # Reply with 'not enough funds' message + msg = self.ctb.jenv.get_template('redeem-low-funds.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::redeem(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('failed') + return False + + # Transfer coins + if self.ctb.coins[self.coin].sendtouser(_userfrom=self.ctb.conf.reddit.redeem.account, _userto=self.u_from.name, _amount=self.coinval, _minconf=1): + # Success, send confirmation + msg = self.ctb.jenv.get_template('redeem-confirmation.tpl').render(a=self, ctb=self.ctb) + lg.debug("CtbAction::redeem(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('completed') + return True + else: + raise Exception("CtbAction::redeem(): sendtouser failed") + + def rates(self, fiat='usd'): + """ + Send info on coin exchange rates + """ + lg.debug("> CtbAction::rates()") + + coins = [] + exchanges = [] + rates = {} + + # Get exchange rates + for coin in self.ctb.coins: + coins.append(coin) + rates[coin] = {'average': {}} + rates[coin]['average']['btc'] = self.ctb.runtime['ev'][coin]['btc'] + rates[coin]['average'][fiat] = self.ctb.runtime['ev'][coin]['btc'] * self.ctb.runtime['ev']['btc'][fiat] + for exchange in self.ctb.exchanges: + try: + rates[coin][exchange] = {} + if self.ctb.exchanges[exchange].supports_pair(_name1=coin, _name2='btc'): + rates[coin][exchange]['btc'] = self.ctb.exchanges[exchange].get_ticker_value(_name1=coin, _name2='btc') + if coin == 'btc' and self.ctb.exchanges[exchange].supports_pair(_name1='btc', _name2=fiat): + # Use exchange value to calculate btc's fiat value + rates[coin][exchange][fiat] = rates[coin][exchange]['btc'] * self.ctb.exchanges[exchange].get_ticker_value(_name1='btc', _name2=fiat) + else: + # Use average value to calculate coin's fiat value + rates[coin][exchange][fiat] = rates[coin][exchange]['btc'] * self.ctb.runtime['ev']['btc'][fiat] + else: + rates[coin][exchange]['btc'] = None + rates[coin][exchange][fiat] = None + except TypeError as e: + msg = self.ctb.jenv.get_template('rates-error.tpl').render(exchange=exchange, a=self, ctb=self.ctb) + lg.debug("CtbAction::rates(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('failed') + return False + + for exchange in self.ctb.exchanges: + exchanges.append(exchange) + + lg.debug("CtbAction::rates(): %s", rates) + + # Send message + msg = self.ctb.jenv.get_template('rates.tpl').render(coins=sorted(coins), exchanges=sorted(exchanges), rates=rates, fiat=fiat, a=self, ctb=self.ctb) + lg.debug("CtbAction::rates(): %s", msg) + ctb_misc.praw_call(self.msg.reply, msg) + self.save('completed') + return True + +def init_regex(ctb): + """ + Initialize regular expressions used to match messages and comments + """ + lg.debug("> init_regex()") + + cc = ctb.conf.coins + fiat = ctb.conf.fiat + actions = ctb.conf.regex.actions + ctb.runtime['regex'] = [] + + for a in vars(actions): + if actions[a].simple: + + # Add simple message actions (info, register, accept, decline, history, rates) + + entry = ctb_misc.DotDict( + {'regex': actions[a].regex, + 'action': a, + 'rg_amount': 0, + 'rg_keyword': 0, + 'rg_address': 0, + 'rg_to_user': 0, + 'coin': None, + 'fiat': None, + 'keyword': None + }) + lg.debug("init_regex(): ADDED %s: %s", entry.action, entry.regex) + ctb.runtime['regex'].append(entry) + + else: + + # Add non-simple actions (givetip, redeem, withdraw) + + for r in sorted(vars(actions[a].regex)): + lg.debug("init_regex(): processing regex %s", actions[a].regex[r].value) + rval1 = actions[a].regex[r].value + rval1 = rval1.replace('{REGEX_TIP_INIT}', ctb.conf.regex.values.tip_init.regex) + rval1 = rval1.replace('{REGEX_USER}', ctb.conf.regex.values.username.regex) + rval1 = rval1.replace('{REGEX_AMOUNT}', ctb.conf.regex.values.amount.regex) + rval1 = rval1.replace('{REGEX_KEYWORD}', ctb.conf.regex.values.keywords.regex) + + if actions[a].regex[r].rg_coin > 0: + + for c in sorted(vars(cc)): + + if not cc[c].enabled: + continue + # lg.debug("init_regex(): processing coin %s", c) + + rval2 = rval1.replace('{REGEX_COIN}', cc[c].regex.units) + rval2 = rval2.replace('{REGEX_ADDRESS}', cc[c].regex.address) + + if actions[a].regex[r].rg_fiat > 0: + + for f in sorted(vars(fiat)): + + if not fiat[f].enabled: + continue + # lg.debug("init_regex(): processing fiat %s", f) + + rval3 = rval2.replace('{REGEX_FIAT}', fiat[f].regex.units) + entry = ctb_misc.DotDict( + {'regex': rval3, + 'action': a, + 'rg_amount': actions[a].regex[r].rg_amount, + 'rg_keyword': actions[a].regex[r].rg_keyword, + 'rg_address': actions[a].regex[r].rg_address, + 'rg_to_user': actions[a].regex[r].rg_to_user, + 'coin': cc[c].unit, + 'fiat': fiat[f].unit + }) + lg.debug("init_regex(): ADDED %s: %s", entry.action, entry.regex) + ctb.runtime['regex'].append(entry) + + else: + + entry = ctb_misc.DotDict( + {'regex': rval2, + 'action': a, + 'rg_amount': actions[a].regex[r].rg_amount, + 'rg_keyword': actions[a].regex[r].rg_keyword, + 'rg_address': actions[a].regex[r].rg_address, + 'rg_to_user': actions[a].regex[r].rg_to_user, + 'coin': cc[c].unit, + 'fiat': None + }) + lg.debug("init_regex(): ADDED %s: %s", entry.action, entry.regex) + ctb.runtime['regex'].append(entry) + + elif actions[a].regex[r].rg_fiat > 0: + + for f in sorted(vars(fiat)): + + if not fiat[f].enabled: + continue + # lg.debug("init_regex(): processing fiat %s", f) + + rval2 = rval1.replace('{REGEX_FIAT}', fiat[f].regex.units) + entry = ctb_misc.DotDict( + {'regex': rval2, + 'action': a, + 'rg_amount': actions[a].regex[r].rg_amount, + 'rg_keyword': actions[a].regex[r].rg_keyword, + 'rg_address': actions[a].regex[r].rg_address, + 'rg_to_user': actions[a].regex[r].rg_to_user, + 'coin': None, + 'fiat': fiat[f].unit + }) + lg.debug("init_regex(): ADDED %s: %s", entry.action, entry.regex) + ctb.runtime['regex'].append(entry) + + elif actions[a].regex[r].rg_keyword > 0: + + entry = ctb_misc.DotDict( + {'regex': rval1, + 'action': a, + 'rg_amount': actions[a].regex[r].rg_amount, + 'rg_keyword': actions[a].regex[r].rg_keyword, + 'rg_address': actions[a].regex[r].rg_address, + 'rg_to_user': actions[a].regex[r].rg_to_user, + 'coin': None, + 'fiat': None + }) + lg.debug("init_regex(): ADDED %s: %s", entry.action, entry.regex) + ctb.runtime['regex'].append(entry) + + lg.info("< init_regex() DONE (%s expressions)", len(ctb.runtime['regex'])) + return None + +def eval_message(msg, ctb): + """ + Evaluate message body and return a CtbAction + object if successful + """ + lg.debug("> eval_message()") + + body = msg.body + for r in ctb.runtime['regex']: + + # Attempt a match + rg = re.compile(r.regex, re.IGNORECASE|re.DOTALL) + #lg.debug("matching '%s' with '%s'", msg.body, r.regex) + m = rg.search(body) + + if m: + # Match found + lg.debug("eval_message(): match found") + + # Extract matched fields into variables + u_from = msg.author + u_to = m.group(r.rg_to_user)[1:] if r.rg_to_user > 0 else None + to_addr = m.group(r.rg_address) if r.rg_address > 0 else None + amount = m.group(r.rg_amount) if r.rg_amount > 0 else None + keyword = m.group(r.rg_keyword) if r.rg_keyword > 0 else None + + # Ignore 'givetip' without u_to and without to_addr + if r.action == 'givetip' and not u_to and not to_addr: + lg.warning("eval_message(): givetip: no to_user and no to_addr specified, ignoring") + return None + + # Return CtbAction instance with given variables + lg.debug("eval_message(): creating action %s: from_user=%s, to_addr=%s, amount=%s, coin=%s, fiat=%s" % (r.action, u_from, to_addr, amount, r.coin, r.fiat)) + try: + action = CtbAction( + atype=r.action, + msg=msg, + from_user=u_from, + to_user=u_to, + to_addr=to_addr, + coin=r.coin, + coin_val=amount if not r.fiat else None, + fiat=r.fiat, + fiat_val=amount if r.fiat else None, + keyword=keyword, + ctb=ctb) + return action + except CtbActionExc as e: + lg.warning("eval_message(): " + str(e)) + return None + + # No match found + lg.debug("eval_message(): no match found") + return None + +def eval_comment(comment, ctb): + """ + Evaluate comment body and return a CtbAction object if successful + """ + lg.debug("> eval_comment()") + + body = comment.body + for r in ctb.runtime['regex']: + + # Skip non-public actions + if not ctb.conf.regex.actions[r.action].public: + continue + + # Attempt a match + rg = re.compile(r.regex, re.IGNORECASE|re.DOTALL) + #lg.debug("eval_comment(): matching '%s' with <%s>", comment.body, r.regex) + m = rg.search(body) + + if m: + # Match found + lg.debug("eval_comment(): match found") + + # Extract matched fields into variables + u_to = m.group(r.rg_to_user)[1:] if r.rg_to_user > 0 else None + to_addr = m.group(r.rg_address) if r.rg_address > 0 else None + amount = m.group(r.rg_amount) if r.rg_amount > 0 else None + keyword = m.group(r.rg_keyword) if r.rg_keyword > 0 else None + + # If no destination mentioned, find parent submission's author + if not u_to and not to_addr: + # set u_to to author of parent comment + u_to = ctb_misc.reddit_get_parent_author(comment, ctb.reddit, ctb) + if not u_to: + # couldn't determine u_to, giving up + lg.warning("eval_comment(): couldn't determine u_to, giving up") + return None + + # Check if from_user == to_user + if u_to and comment.author.name.lower() == u_to.lower(): + lg.warning("eval_comment(): comment.author.name == u_to, ignoring comment", comment.author.name) + return None + + # Return CtbAction instance with given variables + lg.debug("eval_comment(): creating action %s: to_user=%s, to_addr=%s, amount=%s, coin=%s, fiat=%s" % (r.action, u_to, to_addr, amount, r.coin, r.fiat)) + try: + action = CtbAction( + atype=r.action, + msg=comment, + to_user=u_to, + to_addr=to_addr, + coin=r.coin, + coin_val=amount if not r.fiat else None, + fiat=r.fiat, + fiat_val=amount if r.fiat else None, + keyword=keyword, + subr=comment.subreddit, + ctb=ctb) + return action + except CtbActionExc as e: + lg.warning("eval_comment(): " + str(e)) + return None + + # No match found + lg.debug("< eval_comment() DONE (no match)") + return None + +def check_action(atype=None, state=None, coin=None, msg_id=None, created_utc=None, from_user=None, to_user=None, subr=None, ctb=None, is_pending=False): + """ + Return True if action with given attributes exists in database + """ + lg.debug("> check_action(%s)", atype) + + # Build SQL query + sql = "SELECT * FROM t_action" + sql_terms = [] + if atype or state or coin or msg_id or created_utc or from_user or to_user or subr or is_pending: + sql += " WHERE " + if atype: + sql_terms.append("type = '%s'" % atype) + if state: + sql_terms.append("state = '%s'" % state) + if coin: + sql_terms.append("coin = '%s'" % coin) + if msg_id: + sql_terms.append("msg_id = '%s'" % msg_id) + if created_utc: + sql_terms.append("created_utc = %s" % created_utc) + if from_user: + sql_terms.append("from_user = '%s'" % from_user.lower()) + if to_user: + sql_terms.append("to_user = '%s'" % to_user.lower()) + if subr: + sql_terms.append("subreddit = '%s'" % subr) + if is_pending: + sql_terms.append("state <> 'pending'") + sql += ' AND '.join(sql_terms) + + try: + lg.debug("check_action(): <%s>", sql) + mysqlexec = ctb.db.execute(sql) + if mysqlexec.rowcount <= 0: + lg.debug("< check_action() DONE (no)") + return False + else: + lg.debug("< check_action() DONE (yes)") + return True + except Exception as e: + lg.error("check_action(): error executing <%s>: %s", sql, e) + raise + + lg.warning("< check_action() DONE (should not get here)") + return None + +def get_actions(atype=None, state=None, coin=None, msg_id=None, created_utc=None, from_user=None, to_user=None, subr=None, ctb=None): + """ + Return an array of CtbAction objects from database with given attributes + """ + lg.debug("> get_actions(%s)", atype) + + # Build SQL query + sql = "SELECT * FROM t_action" + sql_terms = [] + if atype or state or coin or msg_id or created_utc or from_user or to_user or subr: + sql += " WHERE " + if atype: + sql_terms.append("type = '%s'" % atype) + if state: + sql_terms.append("state = '%s'" % state) + if coin: + sql_terms.append("coin = '%s'" % coin) + if msg_id: + sql_terms.append("msg_id = '%s'" % msg_id) + if created_utc: + sql_terms.append("created_utc %s" % created_utc) + if from_user: + sql_terms.append("from_user = '%s'" % from_user.lower()) + if to_user: + sql_terms.append("to_user = '%s'" % to_user.lower()) + if subr: + sql_terms.append("subreddit = '%s'" % subr) + sql += ' AND '.join(sql_terms) + + while True: + try: + r = [] + lg.debug("get_actions(): <%s>", sql) + mysqlexec = ctb.db.execute(sql) + + if mysqlexec.rowcount <= 0: + lg.debug("< get_actions() DONE (no)") + return r + + for m in mysqlexec: + lg.debug("get_actions(): found %s / %s", m['msg_link'], m['msg_id']) + + # Get PRAW message/comment pointer (msg) + msg = None + if m['msg_link']: + submission = ctb_misc.praw_call(ctb.reddit.get_submission, m['msg_link']) + if not len(submission.comments) > 0: + lg.warning("get_actions(): could not fetch msg (deleted?) from msg_link %s", m['msg_link']) + else: + # msg points to comment that initiated the action + msg = submission.comments[0] + # check if msg.author is present + if not msg.author: + lg.warning("get_actions(): could not fetch msg.author (deleted?) from msg_link %s", m['msg_link']) + #elif m['msg_id']: + # msg = praw.objects.Message(ctb.reddit, {'id': m['msg_id']}) + + r.append( CtbAction( atype=atype, + msg=msg, + from_user=m['from_user'], + to_user=m['to_user'], + to_addr=m['to_addr'] if not m['to_user'] else None, + coin=m['coin'], + fiat=m['fiat'], + coin_val=float(m['coin_val']) if m['coin_val'] else None, + fiat_val=float(m['fiat_val']) if m['fiat_val'] else None, + subr=m['subreddit'], + ctb=ctb, + msg_id=m['msg_id'])) + + lg.debug("< get_actions() DONE (yes)") + return r + + except Exception as e: + lg.error("get_actions(): error executing <%s>: %s", sql, e) + raise + + lg.warning("< get_actions() DONE (should not get here)") + return None diff --git a/src/ctb/ctb_coin.py b/src/ctb/ctb_coin.py new file mode 100644 index 0000000..4001630 --- /dev/null +++ b/src/ctb/ctb_coin.py @@ -0,0 +1,246 @@ +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +import logging, re, time +from pifkoin.bitcoind import Bitcoind, BitcoindException +from httplib import CannotSendRequest + +lg = logging.getLogger('cointipbot') + +class CtbCoin(object): + """ + Coin class for cointip bot + """ + + conn = None + conf = None + + def __init__(self, _conf = None): + """ + Initialize CtbCoin with given parameters. _conf is a coin config dictionary defined in conf/coins.yml + """ + + # verify _conf is a config dictionary + if not _conf or not hasattr(_conf, 'name') or not hasattr(_conf, 'config_file') or not hasattr(_conf, 'txfee'): + raise Exception("CtbCoin::__init__(): _conf is empty or invalid") + + self.conf = _conf + + # connect to coin daemon + try: + lg.debug("CtbCoin::__init__(): connecting to %s...", self.conf.name) + self.conn = Bitcoind(self.conf.config_file, rpcserver=self.conf.config_rpcserver) + except BitcoindException as e: + lg.error("CtbCoin::__init__(): error connecting to %s using %s: %s", self.conf.name, self.conf.config_file, e) + raise + + lg.info("CtbCoin::__init__():: connected to %s", self.conf.name) + time.sleep(0.5) + + # set transaction fee + lg.info("Setting tx fee of %f", self.conf.txfee) + self.conn.settxfee(self.conf.txfee) + + def getbalance(self, _user = None, _minconf = None): + """ + Get user's tip or withdraw balance. _minconf is number of confirmations to use. + Returns (float) balance + """ + lg.debug("CtbCoin::getbalance(%s, %s)", _user, _minconf) + + user = self.verify_user(_user=_user) + minconf = self.verify_minconf(_minconf=_minconf) + balance = float(0) + + try: + balance = self.conn.getbalance(user, minconf) + except BitcoindException as e: + lg.error("CtbCoin.getbalance(): error getting %s (minconf=%s) balance for %s: %s", self.conf.name, minconf, user, e) + raise + + time.sleep(0.5) + return float(balance) + + def sendtouser(self, _userfrom = None, _userto = None, _amount = None, _minconf = 1): + """ + Transfer (move) coins to user + Returns (bool) + """ + lg.debug("CtbCoin::sendtouser(%s, %s, %.9f)", _userfrom, _userto, _amount) + + userfrom = self.verify_user(_user=_userfrom) + userto = self.verify_user(_user=_userto) + amount = self.verify_amount(_amount=_amount) + + # send request to coin daemon + try: + lg.info("CtbCoin::sendtouser(): moving %.9f %s from %s to %s", amount, self.conf.name, userfrom, userto) + result = self.conn.move(userfrom, userto, amount) + time.sleep(0.5) + except Exception as e: + lg.error("CtbCoin::sendtouser(): error moving %.9f %s from %s to %s: %s", amount, self.conf.name, userfrom, userto, e) + return False + + time.sleep(0.5) + return True + + def sendtoaddr(self, _userfrom = None, _addrto = None, _amount = None): + """ + Send coins to address + Returns (string) txid + """ + lg.debug("CtbCoin::sendtoaddr(%s, %s, %.9f)", _userfrom, _addrto, _amount) + + userfrom = self.verify_user(_user=_userfrom) + addrto = self.verify_addr(_addr=_addrto) + amount = self.verify_amount(_amount=_amount) + minconf = self.verify_minconf(_minconf=self.conf.minconf.withdraw) + txid = "" + + # send request to coin daemon + try: + lg.info("CtbCoin::sendtoaddr(): sending %.9f %s from %s to %s", amount, self.conf.name, userfrom, addrto) + + # Unlock wallet, if applicable + if hasattr(self.conf, 'walletpassphrase'): + lg.debug("CtbCoin::sendtoaddr(): unlocking wallet...") + self.conn.walletpassphrase(self.conf.walletpassphrase, 1) + + # Perform transaction + lg.debug("CtbCoin::sendtoaddr(): calling sendfrom()...") + txid = self.conn.sendfrom(userfrom, addrto, amount, minconf) + + # Lock wallet, if applicable + if hasattr(self.conf, 'walletpassphrase'): + lg.debug("CtbCoin::sendtoaddr(): locking wallet...") + self.conn.walletlock() + + except Exception as e: + lg.error("CtbCoin::sendtoaddr(): error sending %.9f %s from %s to %s: %s", amount, self.conf.name, userfrom, addrto, e) + raise + + time.sleep(0.5) + return str(txid) + + def validateaddr(self, _addr = None): + """ + Verify that _addr is a valid coin address + Returns (bool) + """ + lg.debug("CtbCoin::validateaddr(%s)", _addr) + + addr = self.verify_addr(_addr=_addr) + addr_valid = self.conn.validateaddress(addr) + time.sleep(0.5) + + if not addr_valid.has_key('isvalid') or not addr_valid['isvalid']: + lg.debug("CtbCoin::validateaddr(%s): not valid", addr) + return False + else: + lg.debug("CtbCoin::validateaddr(%s): valid", addr) + return True + + def getnewaddr(self, _user = None): + """ + Generate a new address for _user + Returns (string) address + """ + + user = self.verify_user(_user=_user) + addr = "" + counter = 0 + + while True: + try: + # Unlock wallet for keypoolrefill + if hasattr(self.conf, 'walletpassphrase'): + self.conn.walletpassphrase(self.conf.walletpassphrase, 1) + + # Generate new address + addr = self.conn.getnewaddress(user) + + # Lock wallet + if hasattr(self.conf, 'walletpassphrase'): + self.conn.walletlock() + + if not addr: + raise Exception("CtbCoin::getnewaddr(%s): empty addr", user) + + time.sleep(0.1) + return str(addr) + + except BitcoindException as e: + lg.error("CtbCoin::getnewaddr(%s): BitcoindException: %s", user, e) + raise + except CannotSendRequest as e: + if counter < 3: + lg.warning("CtbCoin::getnewaddr(%s): CannotSendRequest, retrying") + counter += 1 + time.sleep(10) + continue + else: + raise + except Exception as e: + if str(e) == "timed out" and counter < 3: + lg.warning("CtbCoin::getnewaddr(%s): timed out, retrying") + counter += 1 + time.sleep(10) + continue + else: + lg.error("CtbCoin::getnewaddr(%s): Exception: %s", user, e) + raise + + + def verify_user(self, _user = None): + """ + Verify and return a username + """ + + if not _user or not type(_user) in [str, unicode]: + raise Exception("CtbCoin::verify_user(): _user wrong type (%s) or empty (%s)", type(_user), _user) + + return str(_user.lower()) + + def verify_addr(self, _addr = None): + """ + Verify and return coin address + """ + + if not _addr or not type(_addr) in [str, unicode]: + raise Exception("CtbCoin::verify_addr(): _addr wrong type (%s) or empty (%s)", type(_addr),_addr) + + return re.escape(str(_addr)) + + def verify_amount(self, _amount = None): + """ + Verify and return amount + """ + + if not _amount or not type(_amount) in [int, float] or not _amount > 0: + raise Exception("CtbCoin::verify_amount(): _amount wrong type (%s), empty, or negative (%s)", type(_amount), _amount) + + return _amount + + def verify_minconf(self, _minconf = None): + """ + Verify and return minimum number of confirmations + """ + + if not _minconf or not type(_minconf) == int or not _minconf >= 0: + raise Exception("CtbCoin::verify_minconf(): _minconf wrong type (%s), empty, or negative (%s)", type(_minconf), _minconf) + + return _minconf diff --git a/src/ctb/ctb_db.py b/src/ctb/ctb_db.py new file mode 100644 index 0000000..836c770 --- /dev/null +++ b/src/ctb/ctb_db.py @@ -0,0 +1,33 @@ +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +from sqlalchemy import create_engine, Table, Column, Integer, String, MetaData, ForeignKey, Numeric, UnicodeText +from sqlalchemy.pool import SingletonThreadPool + +class CointipBotDatabase: + + metadata = MetaData() + + def __init__(self, dsn_url): + '''Pass a DSN URL conforming to the SQLAlchemy API''' + self.dsn_url = dsn_url + + def connect(self): + '''Return a connection object''' + engine = create_engine(self.dsn_url, echo_pool=True, poolclass=SingletonThreadPool) + self.metadata.create_all(engine) + return engine diff --git a/src/ctb/ctb_exchange.py b/src/ctb/ctb_exchange.py new file mode 100644 index 0000000..29e52fe --- /dev/null +++ b/src/ctb/ctb_exchange.py @@ -0,0 +1,128 @@ +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +import json, logging, urllib2, httplib + +lg = logging.getLogger('cointipbot') + +class CtbExchange(object): + """ + Exchange class for cointip bot + """ + + conf = None + + def __init__(self, _conf = None): + """ + Initialize CtbExchange with given parameters. + _conf is an exchange config dictionary defind in conf/exchanges.yml + """ + + if not _conf or not hasattr(_conf, 'urlpaths') or not hasattr(_conf, 'jsonpaths') or not hasattr(_conf, 'coinlist') or not hasattr(_conf, 'fiatlist'): + raise Exception("CtbExchange::__init__(): _conf is empty or invalid") + + self.conf = _conf + + # Convert coinlist and fiatlist values to lowercase + self.conf.coinlist = map(lambda x:x.lower(), self.conf.coinlist) + self.conf.fiatlist = map(lambda x:x.lower(), self.conf.fiatlist) + + lg.debug("CtbExchange::__init__(): initialized exchange %s" % self.conf.domain) + + def supports(self, _name = None): + """ + Return True if exchange supports given coin/fiat _name + """ + + if not _name or not type(_name) in [str, unicode]: + raise Exception("CtbExchange::supports(): _name is empty or wrong type") + + name = str(_name).lower() + + if name in self.conf.coinlist or name in self.conf.fiatlist: + #lg.debug("CtbExchange::supports(%s): YES" % name) + return True + else: + #lg.debug("CtbExchange::supports(%s): NO" % name) + return False + + def supports_pair(self, _name1 = None, _name2 = None): + """ + Return true of exchange supports given coin/fiat pair + """ + + return self.supports(_name=_name1) and self.supports(_name=_name2) + + def get_ticker_value(self, _name1 = None, _name2 = None): + """ + Return (float) ticker value for given pair + """ + + if _name1 == _name2: + return float(1) + + if not self.supports_pair(_name1=_name1, _name2=_name2): + raise Exception("CtbExchange::get_ticker_value(%s, %s, %s): pair not supported" % (self.conf.domain, _name1, _name2)) + + results = [] + for myurlpath in self.conf.urlpaths: + for myjsonpath in self.conf.jsonpaths: + + toreplace = {'{THING_FROM}': _name1.upper() if self.conf.uppercase else _name1.lower(), '{THING_TO}': _name2.upper() if self.conf.uppercase else _name2.lower()} + for t in toreplace: + myurlpath = myurlpath.replace(t, toreplace[t]) + myjsonpath = myjsonpath.replace(t, toreplace[t]) + + try: + lg.debug("CtbExchange::get_ticker_value(%s, %s, %s): calling %s to get %s...", self.conf.domain, _name1, _name2, myurlpath, myjsonpath) + if self.conf.https: + connection = httplib.HTTPSConnection(self.conf.domain, timeout=5) + connection.request("GET", myurlpath, {}, {}) + else: + connection = httplib.HTTPConnection(self.conf.domain, timeout=5) + connection.request("GET", myurlpath) + response = json.loads(connection.getresponse().read()) + result = xpath_get(response, myjsonpath) + lg.debug("CtbExchange::get_ticker_value(%s, %s, %s): result: %.6f", self.conf.domain, _name1, _name2, float(result)) + results.append( float(result) ) + + except urllib2.URLError as e: + lg.error("CtbExchange::get_ticker_value(%s, %s, %s): %s", self.conf.domain, _name1, _name2, e) + return 0.0 + except urllib2.HTTPError as e: + lg.error("CtbExchange::get_ticker_value(%s, %s, %s): %s", self.conf.domain, _name1, _name2, e) + return 0.0 + except Exception as e: + lg.error("CtbExchange::get_ticker_value(%s, %s, %s): %s", self.conf.domain, _name1, _name2, e) + return 0.0 + + # Return average of all responses + return ( sum(results) / float(len(results)) ) + + +def xpath_get(mydict, path): + elem = mydict + try: + for x in path.strip('.').split('.'): + try: + x = int(x) + elem = elem[x] + except ValueError: + elem = elem.get(x) + except: + pass + return elem diff --git a/src/ctb/ctb_log.py b/src/ctb/ctb_log.py new file mode 100644 index 0000000..ea4f79e --- /dev/null +++ b/src/ctb/ctb_log.py @@ -0,0 +1,25 @@ +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +import logging + +class LevelFilter(logging.Filter): + def __init__(self, level): + self.level = level + + def filter(self, record): + return record.levelno >= self.level diff --git a/src/ctb/ctb_misc.py b/src/ctb/ctb_misc.py new file mode 100644 index 0000000..bba3103 --- /dev/null +++ b/src/ctb/ctb_misc.py @@ -0,0 +1,203 @@ +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +import ctb_user + +import logging, time + +from requests.exceptions import HTTPError, ConnectionError, Timeout +from praw.errors import ExceptionList, APIException, InvalidCaptcha, InvalidUser, RateLimitExceeded +from socket import timeout + +lg = logging.getLogger('cointipbot') + + +def praw_call(prawFunc, *extraArgs, **extraKwArgs): + """ + Call prawFunc() with extraArgs and extraKwArgs + Retry if Reddit is down + """ + + while True: + + try: + res = prawFunc(*extraArgs, **extraKwArgs) + return res + + except (HTTPError, ConnectionError, Timeout, timeout) as e: + if str(e) in [ "400 Client Error: Bad Request", "403 Client Error: Forbidden", "404 Client Error: Not Found" ]: + lg.warning("praw_call(): Reddit returned error (%s)", e) + return False + else: + lg.warning("praw_call(): Reddit returned error (%s), sleeping...", e) + time.sleep(30) + pass + except APIException as e: + if str(e) == "(DELETED_COMMENT) `that comment has been deleted` on field `parent`": + lg.warning("praw_call(): deleted comment: %s", e) + return False + else: + raise + except RateLimitExceeded as e: + lg.warning("praw_call(): rate limit exceeded, sleeping for %s seconds", e.sleep_time) + time.sleep(e.sleep_time) + time.sleep(1) + pass + except Exception as e: + raise + + return True + +def reddit_get_parent_author(comment, reddit, ctb): + """ + Return author of comment's parent comment + """ + lg.debug("> reddit_get_parent_author()") + + while True: + + try: + + parentpermalink = comment.permalink.replace(comment.id, comment.parent_id[3:]) + commentlinkid = None + if hasattr(comment, 'link_id'): + commentlinkid = comment.link_id[3:] + else: + comment2 = reddit.get_submission(comment.permalink).comments[0] + commentlinkid = comment2.link_id[3:] + parentid = comment.parent_id[3:] + + if commentlinkid == parentid: + parentcomment = reddit.get_submission(parentpermalink) + else: + parentcomment = reddit.get_submission(parentpermalink).comments[0] + + if parentcomment and hasattr(parentcomment, 'author') and parentcomment.author: + lg.debug("< reddit_get_parent_author(%s) -> %s", comment.id, parentcomment.author.name) + return parentcomment.author.name + else: + lg.warning("< reddit_get_parent_author(%s) -> NONE", comment.id) + return None + + except (IndexError, APIException) as e: + lg.warning("reddit_get_parent_author(): couldn't get author: %s", e) + return None + except (HTTPError, RateLimitExceeded, timeout) as e: + if str(e) in [ "400 Client Error: Bad Request", "403 Client Error: Forbidden", "404 Client Error: Not Found" ]: + lg.warning("reddit_get_parent_author(): Reddit returned error (%s)", e) + return None + else: + lg.warning("reddit_get_parent_author(): Reddit returned error (%s), sleeping...", e) + time.sleep(ctb.conf.misc.times.sleep_seconds) + pass + except Exception as e: + raise + + lg.error("reddit_get_parent_author(): returning None (should not get here)") + return None + +def get_value(conn, param0=None): + """ + Fetch a value from t_values table + """ + lg.debug("> get_value()") + + if param0 == None: + raise Exception("get_value(): param0 == None") + + value = None + sql = "SELECT value0 FROM t_values WHERE param0 = %s" + + try: + + mysqlrow = conn.execute(sql, (param0)).fetchone() + if mysqlrow == None: + lg.error("get_value(): query <%s> didn't return any rows", sql % (param0)) + return None + value = mysqlrow['value0'] + + except Exception, e: + lg.error("get_value(): error executing query <%s>: %s", sql % (param0), e) + raise + + lg.debug("< get_value() DONE (%s)", value) + return value + +def set_value(conn, param0=None, value0=None): + """ + Set a value in t_values table + """ + lg.debug("> set_value(%s, %s)", param0, value0) + + if param0 == None or value0 == None: + raise Exception("set_value(): param0 == None or value0 == None") + sql = "REPLACE INTO t_values (param0, value0) VALUES (%s, %s)" + + try: + + mysqlexec = conn.execute(sql, (param0, value0)) + if mysqlexec.rowcount <= 0: + lg.error("set_value(): query <%s> didn't affect any rows", sql % (param0, value0)) + return False + + except Exception, e: + lg.error("set_value: error executing query <%s>: %s", sql % (param0, value0), e) + raise + + lg.debug("< set_value() DONE") + return True + +def add_coin(coin, db, coins): + """ + Add new coin address to each user + """ + lg.debug("> add_coin(%s)", coin) + + sql_select = "SELECT username FROM t_users WHERE username NOT IN (SELECT username FROM t_addrs WHERE coin = %s) ORDER BY username" + sql_insert = "REPLACE INTO t_addrs (username, coin, address) VALUES (%s, %s, %s)" + + try: + + mysqlsel = db.execute(sql_select, (coin)) + for m in mysqlsel: + # Generate new coin address for user + new_addr = coins[coin].getnewaddr(_user=m['username']) + lg.info("add_coin(): got new address %s for %s", new_addr, m['username']) + # Add new coin address to MySQL + mysqlins = db.execute(sql_insert, (m['username'].lower(), coin, new_addr)) + if mysqlins.rowcount <= 0: + raise Exception("add_coin(%s): rowcount <= 0 when executing <%s>", coin, sql_insert % (m['username'].lower(), coin, new_addr)) + time.sleep(1) + + except Exception, e: + lg.error("add_coin(%s): error: %s", coin, e) + raise + + lg.debug("< add_coin(%s) DONE", coin) + return True + +class DotDict(object): + def __init__(self, d): + for a, b in d.items(): + if isinstance(b, (list, tuple)): + setattr(self, a, [DotDict(x) if isinstance(x, dict) else x for x in b]) + else: + setattr(self, a, DotDict(b) if isinstance(b, dict) else b) + def __getitem__(self, val): + return getattr(self, val) + def has_key(self, key): + return hasattr(self, key) diff --git a/src/ctb/ctb_stats.py b/src/ctb/ctb_stats.py new file mode 100644 index 0000000..16c79b2 --- /dev/null +++ b/src/ctb/ctb_stats.py @@ -0,0 +1,286 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +import logging, re, time +import ctb_misc + +lg = logging.getLogger('cointipbot') + +def update_stats(ctb=None): + """ + Update stats wiki page + """ + + stats = "" + + if not ctb.conf.reddit.stats.enabled: + return None + + for s in sorted(vars(ctb.conf.db.sql.globalstats)): + lg.debug("update_stats(): getting stats for '%s'" % s) + sql = ctb.conf.db.sql.globalstats[s].query + stats += "\n\n### %s\n\n" % ctb.conf.db.sql.globalstats[s].name + stats += "%s\n\n" % ctb.conf.db.sql.globalstats[s].desc + + mysqlexec = ctb.db.execute(sql) + if mysqlexec.rowcount <= 0: + lg.warning("update_stats(): query <%s> returned nothing" % ctb.conf.db.sql.globalstats[s].query) + continue + + if ctb.conf.db.sql.globalstats[s].type == "line": + m = mysqlexec.fetchone() + k = mysqlexec.keys()[0] + value = format_value(m, k, '', ctb) + stats += "%s = **%s**\n" % (k, value) + + elif ctb.conf.db.sql.globalstats[s].type == "table": + stats += ("|".join(mysqlexec.keys())) + "\n" + stats += ("|".join([":---"] * len(mysqlexec.keys()))) + "\n" + for m in mysqlexec: + values = [] + for k in mysqlexec.keys(): + values.append(format_value(m, k, '', ctb)) + stats += ("|".join(values)) + "\n" + + else: + lg.error("update_stats(): don't know what to do with type '%s'" % ctb.conf.db.sql.globalstats[s].type) + return False + + stats += "\n" + + lg.debug("update_stats(): updating subreddit '%s', page '%s'" % (ctb.conf.reddit.stats.subreddit, ctb.conf.reddit.stats.page)) + return ctb_misc.praw_call(ctb.reddit.edit_wiki_page, ctb.conf.reddit.stats.subreddit, ctb.conf.reddit.stats.page, stats, "Update by ALTcointip bot") + +def update_tips(ctb=None): + """ + Update page listing all tips + """ + + if not ctb.conf.reddit.stats.enabled: + return None + + # Start building stats page + tip_list = "### All Completed Tips\n\n" + + q = ctb.db.execute(ctb.conf.db.sql.tips.sql_set) + tips = ctb.db.execute(ctb.conf.db.sql.tips.sql_list, (ctb.conf.db.sql.tips.limit)) + tip_list += ("|".join(tips.keys())) + "\n" + tip_list += ("|".join([":---"] * len(tips.keys()))) + "\n" + + # Build tips table + for t in tips: + values = [] + for k in tips.keys(): + values.append(format_value(t, k, '', ctb, compact=True)) + tip_list += ("|".join(values)) + "\n" + + lg.debug("update_tips(): updating subreddit '%s', page '%s'" % (ctb.conf.reddit.stats.subreddit, ctb.conf.reddit.stats.page_tips)) + ctb_misc.praw_call(ctb.reddit.edit_wiki_page, ctb.conf.reddit.stats.subreddit, ctb.conf.reddit.stats.page_tips, tip_list, "Update by ALTcointip bot") + + return True + +def update_all_user_stats(ctb=None): + """ + Update individual user stats for all uers + """ + + if not ctb.conf.reddit.stats.enabled: + lg.error('update_all_user_stats(): stats are not enabled in config.yml') + return None + + users = ctb.db.execute(ctb.conf.db.sql.userstats.users) + for u in users: + update_user_stats(ctb=ctb, username=u['username']) + +def update_user_stats(ctb=None, username=None): + """ + Update individual user stats for given username + """ + + if not ctb.conf.reddit.stats.enabled: + return None + + # List of coins + coins_q = ctb.db.execute(ctb.conf.db.sql.userstats.coins) + coins = [] + for c in coins_q: + coins.append(c['coin']) + + # List of fiat + fiat_q = ctb.db.execute(ctb.conf.db.sql.userstats.fiat) + fiat = [] + for f in fiat_q: + fiat.append(f['fiat']) + + # Start building stats page + user_stats = "### Tipping Summary for /u/%s\n\n" % username + page = ctb.conf.reddit.stats.page + '_' + username + + # Total Tipped + user_stats += "#### Total Tipped (Fiat)\n\n" + user_stats += "fiat|total\n:---|---:\n" + total_tipped = [] + for f in fiat: + mysqlexec = ctb.db.execute(ctb.conf.db.sql.userstats.total_tipped_fiat, (username, f)) + total_tipped_fiat = mysqlexec.fetchone() + if total_tipped_fiat['total_fiat'] != None: + user_stats += "**%s**|%s %.2f\n" % (f, ctb.conf.fiat[f].symbol, total_tipped_fiat['total_fiat']) + total_tipped.append("%s%.2f" % (ctb.conf.fiat[f].symbol, total_tipped_fiat['total_fiat'])) + user_stats += "\n" + + user_stats += "#### Total Tipped (Coins)\n\n" + user_stats += "coin|total\n:---|---:\n" + for c in coins: + mysqlexec = ctb.db.execute(ctb.conf.db.sql.userstats.total_tipped_coin, (username, c)) + total_tipped_coin = mysqlexec.fetchone() + if total_tipped_coin['total_coin'] != None: + user_stats += "**%s**|%s %.6f\n" % (c, ctb.conf.coins[c].symbol, total_tipped_coin['total_coin']) + user_stats += "\n" + + # Total received + user_stats += "#### Total Received (Fiat)\n\n" + user_stats += "fiat|total\n:---|---:\n" + total_received = [] + for f in fiat: + mysqlexec = ctb.db.execute(ctb.conf.db.sql.userstats.total_received_fiat, (username, f)) + total_received_fiat = mysqlexec.fetchone() + if total_received_fiat['total_fiat'] != None: + user_stats += "**%s**|%s %.2f\n" % (f, ctb.conf.fiat[f].symbol, total_received_fiat['total_fiat']) + total_received.append("%s%.2f" % (ctb.conf.fiat[f].symbol, total_received_fiat['total_fiat'])) + user_stats += "\n" + + user_stats += "#### Total Received (Coins)\n\n" + user_stats += "coin|total\n:---|---:\n" + for c in coins: + mysqlexec = ctb.db.execute(ctb.conf.db.sql.userstats.total_received_coin, (username, c)) + total_received_coin = mysqlexec.fetchone() + if total_received_coin['total_coin'] != None: + user_stats += "**%s**|%s %.6f\n" % (c, ctb.conf.coins[c].symbol, total_received_coin['total_coin']) + user_stats += "\n" + + # History + user_stats += "#### History\n\n" + history = ctb.db.execute(ctb.conf.db.sql.userstats.history, (username, username)) + user_stats += ("|".join(history.keys())) + "\n" + user_stats += ("|".join([":---"] * len(history.keys()))) + "\n" + + # Build history table + num_tipped = 0 + num_received = 0 + for m in history: + if m['state'] == 'completed': + if m['from_user'].lower() == username.lower(): + num_tipped += 1 + elif m['to_user'].lower() == username.lower(): + num_received += 1 + values = [] + for k in history.keys(): + values.append(format_value(m, k, username, ctb)) + user_stats += ("|".join(values)) + "\n" + + # Submit changes + lg.debug("update_user_stats(): updating subreddit '%s', page '%s'" % (ctb.conf.reddit.stats.subreddit, page)) + ctb_misc.praw_call(ctb.reddit.edit_wiki_page, ctb.conf.reddit.stats.subreddit, page, user_stats, "Update by ALTcointip bot") + + # Update user flair on subreddit + if ctb.conf.reddit.stats.userflair and ( len(total_tipped) > 0 or len(total_received) > 0 ): + flair = "" + if len(total_tipped) > 0: + flair += "tipped[" + '|'.join(total_tipped) + "]" + flair += " (%d)" % num_tipped + if len(total_received) > 0: + if len(total_tipped) > 0: + flair += " / " + flair += "received[" + '|'.join(total_received) + "]" + flair += " (%d)" % num_received + lg.debug("update_user_stats(): updating flair for %s (%s)", username, flair) + r = ctb_misc.praw_call(ctb.reddit.get_subreddit, ctb.conf.reddit.stats.subreddit) + res = ctb_misc.praw_call(r.set_flair, username, flair, '') + lg.debug(res) + + return True + +def format_value(m, k, username, ctb, compact=False): + """ + Format value for display based on its type + m[k] is the value, k is the database row name + """ + + if not m[k]: + return '-' + + # Format cryptocoin + if type(m[k]) == float and k.find("coin") > -1: + coin_symbol = ctb.conf.coins[m['coin']].symbol + return "%s %.5g" % (coin_symbol, m[k]) + + # Format fiat + elif type(m[k]) == float and ( k.find("fiat") > -1 or k.find("usd") > -1 ): + fiat_symbol = ctb.conf.fiat[m['fiat']].symbol + return "%s %.2f" % (fiat_symbol, m[k]) + + # Format username + elif k.find("user") > -1 and type( m[k] ) in [str, unicode]: + if compact: + return ("**/u/%s**" % m[k]) if m[k].lower() == username.lower() else ("/u/%s" % m[k]) + else: + un = ("**%s**" % m[k]) if m[k].lower() == username.lower() else m[k] + toreturn = "[%s](/u/%s)" % (un, re.escape(m[k])) + if m[k].lower() != username.lower(): + toreturn += "^[[stats]](/r/%s/wiki/%s_%s)" % (ctb.conf.reddit.stats.subreddit, ctb.conf.reddit.stats.page, m[k]) + return toreturn + + # Format address + elif k.find("addr") > -1: + displayaddr = m[k][:6] + "..." + m[k][-5:] + return "[%s](%s%s)" % (displayaddr, ctb.conf.coins[m['coin']].explorer.address, m[k]) + + # Format state + elif k.find("state") > -1: + if m[k] == 'completed': + return unicode('✓', 'utf8') + else: + return m[k] + + # Format type + elif k.find("type") > -1: + if m[k] == 'givetip': + return 'tip' + if compact: + if m[k] == 'withdraw': + return 'w' + if m[k] == 'redeem': + return 'r' + + # Format subreddit + elif k.find("subreddit") > -1: + return "/r/%s" % m[k] + + # Format link + elif k.find("link") > -1: + return "[link](%s)" % m[k] + + # Format time + elif k.find("utc") > -1: + return "%s" % time.strftime('%Y-%m-%d', time.localtime(m[k])) + + # It's something else + else: + return str(m[k]) diff --git a/src/ctb/ctb_user.py b/src/ctb/ctb_user.py new file mode 100644 index 0000000..7defa81 --- /dev/null +++ b/src/ctb/ctb_user.py @@ -0,0 +1,324 @@ +""" + This file is part of ALTcointip. + + ALTcointip is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + ALTcointip is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with ALTcointip. If not, see . +""" + +import ctb_misc + +import logging, time, praw, re + +lg = logging.getLogger('cointipbot') + +class CtbUser(object): + """ + User class for cointip bot + """ + + # Basic properties + name=None + giftamount=None + joindate=None + addr={} + banned=False + + # Objects + prawobj=None + ctb=None + + def __init__(self, name=None, redditobj=None, ctb=None): + """ + Initialize CtbUser object with given parameters + """ + lg.debug("> CtbUser::__init__(%s)", name) + + if not bool(name): + raise Exception("CtbUser::__init__(): name must be set") + self.name = name + + if not bool(ctb): + raise Exception("CtbUser::__init__(): ctb must be set") + self.ctb = ctb + + if bool(redditobj): + self.prawobj = redditobj + + # Determine if user is banned + if ctb.conf.reddit.banned_users: + if ctb.conf.reddit.banned_users.method == 'subreddit': + for u in ctb.reddit.get_banned(ctb.conf.reddit.banned_users.subreddit): + if self.name.lower() == u.name.lower(): + self.banned = True + elif ctb.conf.reddit.banned_users.method == 'list': + for u in ctb.conf.reddit.banned_users.list: + if self.name.lower() == u.lower(): + self.banned = True + else: + lg.warning("CtbUser::__init__(): invalid method '%s' in banned_users config" % ctb.conf.reddit.banned_users.method) + + lg.debug("< CtbUser::__init__(%s) DONE", name) + + def __str__(self): + """ + Return string representation of self + """ + me = "" + me = me % (self.name, self.giftamount, self.joindate, self.addr, self.prawobj, self.banned) + return me + + def get_balance(self, coin=None, kind=None): + """ + If coin is specified, return float with coin balance for user. Else, return a dict with balance of each coin for user. + """ + lg.debug("> CtbUser::balance(%s)", self.name) + + if not bool(coin) or not bool(kind): + raise Exception("CtbUser::balance(%s): coin or kind not set" % self.name) + + # Ask coin daemon for account balance + lg.info("CtbUser::balance(%s): getting %s %s balance", self.name, coin, kind) + balance = self.ctb.coins[coin].getbalance(_user=self.name, _minconf=self.ctb.conf.coins[coin].minconf[kind]) + + lg.debug("< CtbUser::balance(%s) DONE", self.name) + return float(balance) + + def get_addr(self, coin=None): + """ + Return coin address of user + """ + lg.debug("> CtbUser::get_addr(%s, %s)", self.name, coin) + + if hasattr(self.addr, coin): + return self.addr[coin] + + sql = "SELECT address from t_addrs WHERE username = %s AND coin = %s" + mysqlrow = self.ctb.db.execute(sql, (self.name.lower(), coin.lower())).fetchone() + if not mysqlrow: + lg.debug("< CtbUser::get_addr(%s, %s) DONE (no)", self.name, coin) + return None + else: + self.addr[coin] = mysqlrow['address'] + lg.debug("< CtbUser::get_addr(%s, %s) DONE (%s)", self.name, coin, self.addr[coin]) + return self.addr[coin] + + lg.debug("< CtbUser::get_addr(%s, %s) DONE (should never happen)", self.name, coin) + return None + + def is_on_reddit(self): + """ + Return true if username exists Reddit. Also set prawobj pointer while at it. + """ + lg.debug("> CtbUser::is_on_reddit(%s)", self.name) + + # Return true if prawobj is already set + if bool(self.prawobj): + lg.debug("< CtbUser::is_on_reddit(%s) DONE (yes)", self.name) + return True + + try: + self.prawobj = ctb_misc.praw_call(self.ctb.reddit.get_redditor, self.name) + if self.prawobj: + return True + else: + return False + + except Exception as e: + lg.debug("< CtbUser::is_on_reddit(%s) DONE (no)", self.name) + return False + + lg.warning("< CtbUser::is_on_reddit(%s): returning None (shouldn't happen)", self.name) + return None + + def is_registered(self): + """ + Return true if user is registered with CointipBot + """ + lg.debug("> CtbUser::is_registered(%s)", self.name) + + try: + # First, check t_users table + sql = "SELECT * FROM t_users WHERE username = %s" + mysqlrow = self.ctb.db.execute(sql, (self.name.lower())).fetchone() + + if not mysqlrow: + lg.debug("< CtbUser::is_registered(%s) DONE (no)", self.name) + return False + + else: + # Next, check t_addrs table for whether user has correct number of coin addresses + sql_coins = "SELECT COUNT(*) AS count FROM t_addrs WHERE username = %s" + mysqlrow_coins = self.ctb.db.execute(sql_coins, (self.name.lower())).fetchone() + + if int(mysqlrow_coins['count']) != len(self.ctb.coins): + if int(mysqlrow_coins['count']) == 0: + # Bot probably crashed during user registration process + # Delete user + lg.warning("CtbUser::is_registered(%s): deleting user, incomplete registration", self.name) + sql_delete = "DELETE FROM t_users WHERE username = %s" + mysql_res = self.ctb.db.execute(sql_delete, (self.name.lower())) + # User is not registered + return False + else: + raise Exception("CtbUser::is_registered(%s): user has %s coins but %s active" % (self.name, mysqlrow_coins['count'], len(self.ctb.coins))) + + # Set some properties + self.giftamount = mysqlrow['giftamount'] + + # Done + lg.debug("< CtbUser::is_registered(%s) DONE (yes)", self.name) + return True + + except Exception, e: + lg.error("CtbUser::is_registered(%s): error while executing <%s>: %s", self.name, sql % self.name.lower(), e) + raise + + lg.warning("< CtbUser::is_registered(%s): returning None (shouldn't happen)", self.name) + return None + + def tell(self, subj=None, msg=None, msgobj=None): + """ + Send a Reddit message to user + """ + lg.debug("> CtbUser::tell(%s)", self.name) + + if not bool(subj) or not bool(msg): + raise Exception("CtbUser::tell(%s): subj or msg not set", self.name) + + if not self.is_on_reddit(): + raise Exception("CtbUser::tell(%s): not a Reddit user", self.name) + + if bool(msgobj): + lg.debug("CtbUser::tell(%s): replying to message", msgobj.id) + ctb_misc.praw_call(msgobj.reply, msg) + else: + lg.debug("CtbUser::tell(%s): sending message", self.name) + ctb_misc.praw_call(self.prawobj.send_message, subj, msg) + + lg.debug("< CtbUser::tell(%s) DONE", self.name) + return True + + def register(self): + """ + Add user to database and generate coin addresses + """ + lg.debug("> CtbUser::register(%s)", self.name) + + # Add user to database + try: + sql_adduser = "INSERT INTO t_users (username) VALUES (%s)" + mysqlexec = self.ctb.db.execute(sql_adduser, (self.name.lower())) + if mysqlexec.rowcount <= 0: + raise Exception("CtbUser::register(%s): rowcount <= 0 while executing <%s>" % ( self.name, sql_adduser % (self.name.lower()) )) + except Exception, e: + lg.error("CtbUser::register(%s): exception while executing <%s>: %s", self.name, sql_adduser % (self.name.lower()), e) + raise + + # Get new coin addresses + new_addrs = {} + for c in self.ctb.coins: + new_addrs[c] = self.ctb.coins[c].getnewaddr(_user=self.name.lower()) + lg.info("CtbUser::register(%s): got %s address %s", self.name, c, new_addrs[c]) + + # Add coin addresses to database + for c in new_addrs: + try: + sql_addr = "REPLACE INTO t_addrs (username, coin, address) VALUES (%s, %s, %s)" + mysqlexec = self.ctb.db.execute(sql_addr, (self.name.lower(), c, new_addrs[c])) + if mysqlexec.rowcount <= 0: + # Undo change to database + delete_user(_username=self.name.lower(), _db=self.ctb.db) + raise Exception("CtbUser::register(%s): rowcount <= 0 while executing <%s>" % (self.name, sql_addr % (self.name.lower(), c, new_addrs[c]))) + + except Exception, e: + # Undo change to database + delete_user(_username=self.name.lower(), _db=self.ctb.db) + raise + + lg.debug("< CtbUser::register(%s) DONE", self.name) + return True + + def get_redeem_amount(self, coin=None, fiat=None): + """ + Return karma redeem amount for a given coin + """ + lg.debug("> CtbUser::get_redeem_amount(%s)", coin) + + if not coin or not self.ctb.coins.has_key(coin): + raise Exception("CtbUser::get_redeem_amount(%s): invalid coin" % coin) + if not fiat or not self.ctb.conf.fiat.has_key(fiat): + raise Exception("CtbUser::get_redeem_amount(%s): invalid fiat" % fiat) + + # Check if we have coin's fiat value + coin_value = self.ctb.coin_value(coin, fiat) + if not coin_value or not coin_value > 0.0: + lg.warning("CtbUser::get_redeem_amount(%s): coin_value not available", coin) + return (None, None) + + # First, determine fiat value due to link karma + link_mul = self.ctb.conf.reddit.redeem.multiplier.link + if type(link_mul) in [str, unicode]: + link_mul = eval(link_mul) + if not type(link_mul) == float: + raise Exception("CtbUser::get_redeem_amount(): type of link_mul is not float") + link_val = float(self.prawobj.link_karma) * link_mul + + # Second, determine fiat value due to comment karma + comm_mul = self.ctb.conf.reddit.redeem.multiplier.comment + if type(comm_mul) in [str, unicode]: + comm_mul = eval(comm_mul) + if not type(comm_mul) == float: + raise Exception("CtbUser::get_redeem_amount(): type of comm_mul is not float") + comm_val = float(self.prawobj.comment_karma) * comm_mul + + # Third, determine base fiat value from config + base_val = self.ctb.conf.reddit.redeem.base + if type(base_val) in [str, unicode]: + base_val = eval(base_val) + if not type(base_val) == float: + raise Exception("CtbUser::get_redeem_amount(): type of base_val is not float") + + # Sum link_val, comm_val, and base_val to get total fiat + total_fiat = link_val + comm_val + base_val + + # Check if above maximum + if total_fiat > self.ctb.conf.reddit.redeem.maximum: + total_fiat = self.ctb.conf.reddit.redeem.maximum + + # Determine total coin value using exchange rate + total_coin = total_fiat / coin_value + + lg.debug("< CtbUser::get_redeem_amount(%s) DONE", coin) + return (total_coin, total_fiat) + + +def delete_user(_username=None, _db=None): + """ + Delete _username from t_users and t_addrs tables + """ + lg.debug("> delete_user(%s)", _username) + + try: + sql_arr = ["DELETE FROM t_users WHERE username = %s", + "DELETE FROM t_addrs WHERE username = %s"] + for sql in sql_arr: + mysqlexec = _db.execute(sql, _username.lower()) + if mysqlexec.rowcount <= 0: + lg.warning("delete_user(%s): rowcount <= 0 while executing <%s>", _username, sql % _username.lower()) + + except Exception, e: + lg.error("delete_user(%s): error while executing <%s>: %s", _username, sql % _username.lower(), e) + return False + + lg.debug("< delete_user(%s) DONE", _username) + return True diff --git a/src/logs/dummy b/src/logs/dummy new file mode 100644 index 0000000..e69de29 diff --git a/src/tpl/jinja2/address-invalid.tpl b/src/tpl/jinja2/address-invalid.tpl new file mode 100644 index 0000000..cdd0f30 --- /dev/null +++ b/src/tpl/jinja2/address-invalid.tpl @@ -0,0 +1,6 @@ +{% set user_from = a.u_from.name %} +{% set coin_name = a.coin.upper() %} +{% set address = a.addr_to %} +I'm sorry {{ user_from | replace('_', '\_') }}, __{{ coin_name }}__ address __{{ address | escape }}__ appears to be invalid (is there a typo?). + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/already-registered.tpl b/src/tpl/jinja2/already-registered.tpl new file mode 100644 index 0000000..bbf2262 --- /dev/null +++ b/src/tpl/jinja2/already-registered.tpl @@ -0,0 +1,6 @@ +{% set user_from = a.u_from.name %} +{% set user_bot = ctb.conf.reddit.auth.user %} +Sorry {{ user_from | replace('_', '\_') }}, you're already registered! You can view your balances and deposit addresses with __[+info](http://www.reddit.com/message/compose?to={{ user_bot }}&subject=info&message=%2Binfo)__ command. + +For more commands and helpful information on getting started, see the footer of this message. +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/announcement.tpl b/src/tpl/jinja2/announcement.tpl new file mode 100644 index 0000000..e69de29 diff --git a/src/tpl/jinja2/command-disabled.tpl b/src/tpl/jinja2/command-disabled.tpl new file mode 100644 index 0000000..af622f5 --- /dev/null +++ b/src/tpl/jinja2/command-disabled.tpl @@ -0,0 +1,3 @@ +Sorry {{ a.u_from.name | replace('_', '\_') }}, action type '{{ a.type }}' is currently disabled. Please try again later. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/confirmation.tpl b/src/tpl/jinja2/confirmation.tpl new file mode 100644 index 0000000..f9714be --- /dev/null +++ b/src/tpl/jinja2/confirmation.tpl @@ -0,0 +1,67 @@ +{% set title_fmt = "^__[%s]__:" % title %} +{% set user_from_fmt = " ^/u/%s" % a.u_from.name %} +{% set arrow_fmt = " ^->" %} +{% if a.u_to: %} +{% set user_to_fmt = " ^/u/%s" % a.u_to.name %} +{% if ctb.conf.reddit.stats.enabled: %} +{% set stats_user_to_fmt = " ^^[[stats]](%s_%s)" % (ctb.conf.reddit.stats.url, a.u_to.name) %} +{% endif %} +{% endif %} +{% if a.addr_to: %} +{% set ex = ctb.conf.coins[a.coin].explorer %} +{% set user_to_fmt = " ^[%s](%s%s)" % (a.addr_to, ex.address, a.addr_to) %} +{% set arrow_fmt = " ^[->](%s%s)" % (ex.transaction, a.txid) %} +{% endif %} +{% if a.coinval: %} +{% if a.coinval < 0.0001 %} +{% set coin_amount = ( a.coinval * 100000000.0 ) %} +{% set amount_prefix_short = "s" %} +{% set amount_prefix_long = "satoshi" %} +{% elif a.coinval < 1.0 %} +{% set coin_amount = ( a.coinval * 1000.0 ) %} +{% set amount_prefix_short = "m" %} +{% set amount_prefix_long = "milli" %} +{% elif a.coinval >= 1000.0 %} +{% set coin_amount = ( a.coinval / 1000.0 ) %} +{% set amount_prefix_short = "K" %} +{% set amount_prefix_long = "kilo" %} +{% else %} +{% set coin_amount = a.coinval %} +{% endif %} +{% set coin_name = ctb.conf.coins[a.coin].name %} +{% set coin_symbol = ctb.conf.coins[a.coin].symbol %} +{% set coin_amount_fmt = " __^%s%s%.6g ^%s%ss__" % (amount_prefix_short, coin_symbol, coin_amount, amount_prefix_long, coin_name) %} +{% endif %} +{% if a.fiatval: %} +{% set fiat_amount = a.fiatval %} +{% set fiat_symbol = ctb.conf.fiat[a.fiat].symbol %} +{% set fiat_amount_fmt = " ^__(%s%.4f)__" % (fiat_symbol, fiat_amount) %} +{% endif %} +{% if ctb.conf.reddit.stats.enabled: %} +{% set stats_user_from_fmt = " ^^[[stats]](%s_%s)" % (ctb.conf.reddit.stats.url, a.u_from.name) %} +{% set stats_link_fmt = " ^[[global_stats]](%s)" % ctb.conf.reddit.stats.url %} +{% endif %} +{% if ctb.conf.reddit.help.enabled: %} +{% set help_link_fmt = " ^[[help]](%s)" % ctb.conf.reddit.help.url %} +{% endif %} +{% if a.type == 'givetip' and a.keyword and ctb.conf.keywords[a.keyword].message %} +{% set txt = ctb.conf.keywords[a.keyword].message %} +{% if stats_user_from_fmt %} +{% set txt = txt | replace("{USER_FROM}", user_from_fmt + stats_user_from_fmt) %} +{% else %} +{% set txt = txt | replace("{USER_FROM}", user_from_fmt) %} +{% endif %} +{% if stats_user_to_fmt %} +{% set txt = txt | replace("{USER_TO}", user_to_fmt + stats_user_to_fmt) %} +{% else %} +{% set txt = txt | replace("{USER_TO}", user_to_fmt) %} +{% endif %} +{% if fiat_amount_fmt %} +{% set txt = txt | replace("{AMOUNT}", coin_amount_fmt + fiat_amount_fmt) %} +{% else %} +{% set txt = txt | replace("{AMOUNT}", coin_amount_fmt) %} +{% endif %} +{{ txt }} +{% else %} +{{ title_fmt }}{{ user_from_fmt }}{{ stats_user_from_fmt }}{{ arrow_fmt }}{{ user_to_fmt }}{{ stats_user_to_fmt }}{{ coin_amount_fmt }}{{ fiat_amount_fmt }}{{ help_link_fmt }}{{ stats_link_fmt }} +{% endif %} diff --git a/src/tpl/jinja2/didnt-understand.tpl b/src/tpl/jinja2/didnt-understand.tpl new file mode 100644 index 0000000..f6ae9ef --- /dev/null +++ b/src/tpl/jinja2/didnt-understand.tpl @@ -0,0 +1,9 @@ +{% if ctb and ctb.conf.reddit.help.enabled %} +{% set help_link = "[verify syntax](%s)" % ctb.conf.reddit.help.url %} +{% else %} +{% set help_link = "verify syntax" %} +{% endif %} +Sorry {{ user_from | replace('_', '\_') }}, I didn't understand your {{ what }}. Please {{ help_link }} and try again. + +{% set user = user_from %} +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/footer.tpl b/src/tpl/jinja2/footer.tpl new file mode 100644 index 0000000..e29030f --- /dev/null +++ b/src/tpl/jinja2/footer.tpl @@ -0,0 +1,35 @@ +{% if not user and a %} +{% set user = a.u_from.name %} +{% endif %} +{% if not user_bot and ctb %} +{% set user_bot = ctb.conf.reddit.auth.user %} +{% endif %} +{% set compose_url = "http://www.reddit.com/message/compose?to=%s&subject=%s&message=%%2B%s" %} +{% set i_url = compose_url % (user_bot, "info", "info") %} +{% set h_url = compose_url % (user_bot, "history", "history") %} +{% set w_url = compose_url % (user_bot, "withdraw", "withdraw%20ADDRESS%20AMOUNT%20COIN_NAME") %} +{% set r_url = compose_url % (user_bot, "rates", "rates") %} +{% set k_url = compose_url % (user_bot, "redeem", "redeem%20COIN_NAME") %} +{% if ctb and ctb.conf.reddit.help.enabled %} +{% set help_link = " ^[[help]](%s)" % ctb.conf.reddit.help.url %} +{% endif %} +{% if ctb and ctb.conf.reddit.contact.enabled %} +{% set contact_link = " ^[[contact]](%s)" % ctb.conf.reddit.contact.url %} +{% endif %} +{% if ctb and ctb.conf.reddit.stats.enabled %} +{% set stats_user_link = " **^[[your_stats]](%s_%s)**" % (ctb.conf.reddit.stats.url, user) %} +{% set stats_global_link = " ^[[global_stats]](%s)" % ctb.conf.reddit.stats.url %} +{% endif %} +***** + +^Helpful ^Links|  +:---|:--- +{% if a and a.msg and a.msg.permalink %} +^Source ^comment|^[[link]]({{ a.msg.permalink }}) +{% elif source_link %} +^Source ^comment|^[[link]]({{ source_link }}) +{% endif %} +^Quick ^commands|**^[+info]({{ i_url }})** ^[+history]({{ h_url }}) ^[+redeem]({{ k_url }}) ^[+rates]({{ r_url }}) ^[+withdraw]({{ w_url }}) +^Resources|{{ help_link }}{{ contact_link }}{{ stats_user_link }}{{ stats_global_link }} + +{% include 'announcement.tpl' %} diff --git a/src/tpl/jinja2/history.tpl b/src/tpl/jinja2/history.tpl new file mode 100644 index 0000000..2ba1098 --- /dev/null +++ b/src/tpl/jinja2/history.tpl @@ -0,0 +1,11 @@ +{% set user = a.u_from.name %} + +Hello {{ user | replace('_', '\_') }}, here are your last {{ limit }} transactions. + +{{ "|".join(keys) }} +{{ "|".join([":---"] * (keys|length)) }} +{% for h in history %} +{{ "|".join(h) }} +{% endfor %} + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/info.tpl b/src/tpl/jinja2/info.tpl new file mode 100644 index 0000000..e2cdc5f --- /dev/null +++ b/src/tpl/jinja2/info.tpl @@ -0,0 +1,24 @@ +{% set user_from = a.u_from.name %} +{% set fiat_total_fmt = "%s%.2f" % (fiat_symbol, fiat_total) %} + +Hello {{ user_from | replace('_', '\_') }}! Here's your account info. + +coin|deposit address|balance +:---|:---|---: +{% for i in info %} +{% set name_fmt = "%s (%s)" % (ctb.conf.coins[i.coin].name, i.coin.upper()) %} +{% set address_fmt = "%s ^[[ex]](%s%s) ^[[qr]](%s%s)" % (i.address, ctb.conf.coins[i.coin].explorer.address, i.address, ctb.conf.misc.qr_service_url, i.address) %} +{% set coin_bal_fmt = "%.9g" % i.balance %} +{% if i.fiat_balance %} +{% set fiat_bal_fmt = " ^%s%.2f" % ( i.fiat_symbol, i.fiat_balance ) %} +{% else %} +{% set fiat_bal_fmt = "" %} +{% endif %} +__{{ name_fmt }}__|{{ address_fmt }}|__{{ coin_bal_fmt }}{{ fiat_bal_fmt }}__ +{% endfor %} + | |  +__TOTAL__| |__{{ fiat_total_fmt }}__ + +Use addresses above to deposit coins into your account. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/no-coin-balances.tpl b/src/tpl/jinja2/no-coin-balances.tpl new file mode 100644 index 0000000..556c985 --- /dev/null +++ b/src/tpl/jinja2/no-coin-balances.tpl @@ -0,0 +1,5 @@ +{% set user_from = a.u_from.name %} +{% set amount_fmt = "%s%.6g" % (ctb.conf.fiat[a.fiat].symbol, a.fiatval) %} +Sorry {{ user_from | replace('_', '\_') }}, you don't have any coin balances enough for a {{ amount_fmt }} tip. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/no-pending-tips.tpl b/src/tpl/jinja2/no-pending-tips.tpl new file mode 100644 index 0000000..5ddd83e --- /dev/null +++ b/src/tpl/jinja2/no-pending-tips.tpl @@ -0,0 +1,3 @@ +I'm sorry {{ user_from | replace('_', '\_') }}, you don't have any pending tips. Perhaps they've been already confirmed, or already expired. Tips are auto-confirmed after you've registered. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/not-on-reddit.tpl b/src/tpl/jinja2/not-on-reddit.tpl new file mode 100644 index 0000000..87df47d --- /dev/null +++ b/src/tpl/jinja2/not-on-reddit.tpl @@ -0,0 +1,5 @@ +{% set user_from = a.u_from.name %} +{% set user_to = a.u_to.name %} +I'm sorry {{ user_from | replace('_', '\_') }}, your tip has failed because user **{{ user_to | replace('_', '\_') }}** is not on Reddit. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/not-registered.tpl b/src/tpl/jinja2/not-registered.tpl new file mode 100644 index 0000000..5eb28fd --- /dev/null +++ b/src/tpl/jinja2/not-registered.tpl @@ -0,0 +1,5 @@ +{% set user_from = a.u_from.name %} +{% set user_bot = ctb.conf.reddit.auth.user %} +I'm sorry {{ user_from | replace('_', '\_') }}, we've never met. Please __[+register](http://www.reddit.com/message/compose?to={{ user_bot }}&subject=register&message=%2Bregister)__ first! + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/pending-tips-declined.tpl b/src/tpl/jinja2/pending-tips-declined.tpl new file mode 100644 index 0000000..d920966 --- /dev/null +++ b/src/tpl/jinja2/pending-tips-declined.tpl @@ -0,0 +1,3 @@ +Hello {{ user_from | replace('_', '\_') }}, your pending tips have been declined. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/rates-error.tpl b/src/tpl/jinja2/rates-error.tpl new file mode 100644 index 0000000..c991951 --- /dev/null +++ b/src/tpl/jinja2/rates-error.tpl @@ -0,0 +1,4 @@ +{% set user = a.u_from.name %} +Hey {{ user | replace('_', '\_') }}, I couldn't get the rates for you because exchange **{{ exchange }}** didn't respond. Please try again later. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/rates.tpl b/src/tpl/jinja2/rates.tpl new file mode 100644 index 0000000..48e8ac0 --- /dev/null +++ b/src/tpl/jinja2/rates.tpl @@ -0,0 +1,15 @@ +{% set user = a.u_from.name %} + +Hello {{ user | replace('_', '\_') }}, here are the latest exchange rates. + +coin|average{% for e in exchanges %}{{ "|" + e }}{% endfor %} + +:---|---:{% for e in exchanges %}{{ "|---:" }}{% endfor %} +{% for c in coins %} +{{ "\n**%s (%s)**|%s%.6f^%s%.4f" % (ctb.conf.coins[c].name, c.upper(), ctb.conf.coins.btc.symbol, rates[c]['average'].btc, ctb.conf.fiat[fiat].symbol, rates[c]['average'][fiat]) }}{% for e in exchanges %} +{% if rates[c][e].btc and rates[c][e][fiat] %}{{ "|%s%.6f^%s%.4f" % (ctb.conf.coins.btc.symbol, rates[c][e].btc, ctb.conf.fiat[fiat].symbol, rates[c][e][fiat]) }}{% else %}{{ "|-" }}{% endif %} +{% endfor %} +{% endfor %} + + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/redeem-already-done.tpl b/src/tpl/jinja2/redeem-already-done.tpl new file mode 100644 index 0000000..cca9460 --- /dev/null +++ b/src/tpl/jinja2/redeem-already-done.tpl @@ -0,0 +1,7 @@ +I'm sorry {{ a.u_from.name | replace('_', '\_') }}, you've already redeemed your {{ coin }} karma. + +{% if coin %} +Try redeeming a different coin. +{% endif %} + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/redeem-cant-compute.tpl b/src/tpl/jinja2/redeem-cant-compute.tpl new file mode 100644 index 0000000..ff459f1 --- /dev/null +++ b/src/tpl/jinja2/redeem-cant-compute.tpl @@ -0,0 +1,3 @@ +I'm sorry {{ a.u_from.name | replace('_', '\_') }}, I can't get the exchange rate for {{ ctb.conf.coins[a.coin].name }} at this time. Please try again later. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/redeem-confirmation.tpl b/src/tpl/jinja2/redeem-confirmation.tpl new file mode 100644 index 0000000..b4c5c17 --- /dev/null +++ b/src/tpl/jinja2/redeem-confirmation.tpl @@ -0,0 +1,25 @@ +{% if a.coinval: %} +{% if a.coinval < 0.0001 %} +{% set coin_amount = ( a.coinval * 100000000.0 ) %} +{% set amount_prefix_short = "s" %} +{% set amount_prefix_long = "satoshi" %} +{% elif a.coinval < 1.0 %} +{% set coin_amount = ( a.coinval * 1000.0 ) %} +{% set amount_prefix_short = "m" %} +{% set amount_prefix_long = "milli" %} +{% elif a.coinval >= 1000.0 %} +{% set coin_amount = ( a.coinval / 1000.0 ) %} +{% set amount_prefix_short = "K" %} +{% set amount_prefix_long = "kilo" %} +{% else %} +{% set coin_amount = a.coinval %} +{% endif %} +{% endif %} +{% set coinval_fmt = "%s%s%.6g %s%ss" % (amount_prefix_short, ctb.conf.coins[a.coin].symbol, coin_amount, amount_prefix_long, ctb.conf.coins[a.coin].name) %} +{% if amount_prefix_short %} +{% set coinval_fmt = coinval_fmt + " (%s%.9f %ss)" % (ctb.conf.coins[a.coin].symbol, a.coinval, ctb.conf.coins[a.coin].name) %} +{% endif %} +{% set fiatval_fmt = "%s%.4f" % (ctb.conf.fiat[a.fiat].symbol, a.fiatval) %} +Hey {{ a.u_from.name | replace('_', '\_') }}, you have received __{{ coinval_fmt }} ({{ fiatval_fmt }})__ for your karma. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/redeem-low-funds.tpl b/src/tpl/jinja2/redeem-low-funds.tpl new file mode 100644 index 0000000..ab07197 --- /dev/null +++ b/src/tpl/jinja2/redeem-low-funds.tpl @@ -0,0 +1,5 @@ +{% set coinval_fmt = "%s%.9f" % (ctb.conf.coins[a.coin].symbol, a.coinval) %} +{% set fiatval_fmt = "%s%.4f" % (ctb.conf.fiat[a.fiat].symbol, a.fiatval) %} +I'm sorry {{ a.u_from.name | replace('_', '\_') }}, I don't have enough {{ ctb.conf.coins[a.coin].name }}s to give you for your karma (you would've gotten {{ coinval_fmt }} {{ ctb.conf.coins[a.coin].name }}s ({{ fiatval_fmt }})). Try again later, or pick a different coin. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/redeem-low-karma.tpl b/src/tpl/jinja2/redeem-low-karma.tpl new file mode 100644 index 0000000..b67ba12 --- /dev/null +++ b/src/tpl/jinja2/redeem-low-karma.tpl @@ -0,0 +1,3 @@ +I'm sorry {{ a.u_from.name | replace('_', '\_') }}, you total karma needs to be greater or equal to {{ ctb.conf.reddit.redeem.min_karma }} for this feature (you have {{ user_karma }}). + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/tip-already-pending.tpl b/src/tpl/jinja2/tip-already-pending.tpl new file mode 100644 index 0000000..ecd1653 --- /dev/null +++ b/src/tpl/jinja2/tip-already-pending.tpl @@ -0,0 +1,6 @@ +{% set user_from = a.u_from.name %} +{% set user_to = a.u_to.name %} +{% set coin_name = ctb.conf.coins[a.coin].name %} +I'm sorry {{ user_from | replace('_', '\_') }}, /u/{{ user_to }} already has a pending {{ coin_name }} tip from you. Please wait until it's accepted, declined, or expired. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/tip-below-minimum.tpl b/src/tpl/jinja2/tip-below-minimum.tpl new file mode 100644 index 0000000..87c6d54 --- /dev/null +++ b/src/tpl/jinja2/tip-below-minimum.tpl @@ -0,0 +1,9 @@ +{% set user_from = a.u_from.name %} +{% set amount_fmt = "%.9f %s" % (a.coinval, a.coin.upper()) %} +{% set min_fmt = "%.9g" % min_value %} +{% set coin_name = ctb.conf.coins[a.coin].name %} +I'm sorry {{ user_from | replace('_', '\_') }}, your tip/withdraw of __{{ amount_fmt }}__ is below minimum of __{{ min_fmt }}__. I cannot process very small transactions because of high network fee requirement. + +If you really need to withdraw this amount, try depositing some {{ coin_name }}s to meet the minimum limit, then withdrawing everything. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/tip-incoming.tpl b/src/tpl/jinja2/tip-incoming.tpl new file mode 100644 index 0000000..950d285 --- /dev/null +++ b/src/tpl/jinja2/tip-incoming.tpl @@ -0,0 +1,30 @@ +{% set user_from = a.u_from.name %} +{% set user_to = a.u_to.name %} +{% set user_bot = ctb.conf.reddit.auth.user %} +{% if a.coinval: %} +{% if a.coinval < 0.0001 %} +{% set coin_amount = ( a.coinval * 100000000.0 ) %} +{% set amount_prefix_short = "s" %} +{% set amount_prefix_long = "satoshi" %} +{% elif a.coinval < 1.0 %} +{% set coin_amount = ( a.coinval * 1000.0 ) %} +{% set amount_prefix_short = "m" %} +{% set amount_prefix_long = "milli" %} +{% elif a.coinval >= 1000.0 %} +{% set coin_amount = ( a.coinval / 1000.0 ) %} +{% set amount_prefix_short = "K" %} +{% set amount_prefix_long = "kilo" %} +{% else %} +{% set coin_amount = a.coinval %} +{% endif %} +{% endif %} +{% set coinval_fmt = "%s%s%.6g %s%ss" % (amount_prefix_short, ctb.conf.coins[a.coin].symbol, coin_amount, amount_prefix_long, ctb.conf.coins[a.coin].name) %} +{% if amount_prefix_short %} +{% set coinval_fmt = coinval_fmt + " (%s%.9f %ss)" % (ctb.conf.coins[a.coin].symbol, a.coinval, ctb.conf.coins[a.coin].name) %} +{% endif %} +{% set fiatval_fmt = "%s%.4f" % (ctb.conf.fiat[a.fiat].symbol, a.fiatval) %} +{% set expire_days_fmt = "%.2g" % ( ctb.conf.misc.times.expire_pending_hours / 24.0 ) %} +Hey {{ user_to | replace('_', '\_') }}, /u/{{ user_from }} sent you a __{{ coinval_fmt }} ({{ fiatval_fmt }})__ tip, reply with __[+accept](http://www.reddit.com/message/compose?to={{ user_bot }}&subject=accept&message=%2Baccept)__ to claim it. Reply with __[+decline](http://www.reddit.com/message/compose?to={{ user_bot }}&subject=decline&message=%2Bdecline)__ to decline it. __Pending tips expire in {{ expire_days_fmt }} days.__ + +{% set user = user_to %} +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/tip-low-balance.tpl b/src/tpl/jinja2/tip-low-balance.tpl new file mode 100644 index 0000000..873c148 --- /dev/null +++ b/src/tpl/jinja2/tip-low-balance.tpl @@ -0,0 +1,16 @@ +{% set user_from = a.u_from.name %} +{% set balance_fmt = "%.9f %s" % (balance, a.coin.upper()) %} +I'm sorry {{ user_from | replace('_', '\_') }}, your {{ action_name }} balance of **{{ balance_fmt }}** is insufficient for this {{ action_name }}. +{% if action_name == 'withdraw' %} +{% set coin_name = ctb.conf.coins[a.coin].name %} +{% set coin_confs = ctb.conf.coins[a.coin].minconf.withdraw %} +{% set coin_fee_fmt = "%.6g" % ctb.conf.coins[a.coin].txfee %} + +Withdrawals are subject to network confirmations and network fees. {{ coin_name }} requires {{ coin_confs }} confirmations and a {{ coin_fee_fmt }} fee. + +If withdrawal balance above doesn't match your reported tip balance, try waiting for more network confirmations. + +>**Tip:** To withdraw everything, use the `ALL` keyword - `+withdraw ADDRESS ALL {{ coin_name }}` - and I'll automatically deduct the required network fee. +{% endif %} + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/tip-received.tpl b/src/tpl/jinja2/tip-received.tpl new file mode 100644 index 0000000..25ae9fe --- /dev/null +++ b/src/tpl/jinja2/tip-received.tpl @@ -0,0 +1,28 @@ +{% set user_from = a.u_from.name %} +{% set user_to = a.u_to.name %} +{% if a.coinval: %} +{% if a.coinval < 0.0001 %} +{% set coin_amount = ( a.coinval * 100000000.0 ) %} +{% set amount_prefix_short = "s" %} +{% set amount_prefix_long = "satoshi" %} +{% elif a.coinval < 1.0 %} +{% set coin_amount = ( a.coinval * 1000.0 ) %} +{% set amount_prefix_short = "m" %} +{% set amount_prefix_long = "milli" %} +{% elif a.coinval >= 1000.0 %} +{% set coin_amount = ( a.coinval / 1000.0 ) %} +{% set amount_prefix_short = "K" %} +{% set amount_prefix_long = "kilo" %} +{% else %} +{% set coin_amount = a.coinval %} +{% endif %} +{% endif %} +{% set coinval_fmt = "%s%s%.6g %s%ss" % (amount_prefix_short, ctb.conf.coins[a.coin].symbol, coin_amount, amount_prefix_long, ctb.conf.coins[a.coin].name) %} +{% if amount_prefix_short %} +{% set coinval_fmt = coinval_fmt + " (%s%.9f %ss)" % (ctb.conf.coins[a.coin].symbol, a.coinval, ctb.conf.coins[a.coin].name) %} +{% endif %} +{% set fiatval_fmt = "%s%.4f" % (ctb.conf.fiat[a.fiat].symbol, a.fiatval) %} +Hey {{ user_to | replace('_', '\_') }}, you have received a __{{ coinval_fmt }} ({{ fiatval_fmt }})__ tip from /u/{{ user_from }}. + +{% set user = user_to %} +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/tip-went-wrong.tpl b/src/tpl/jinja2/tip-went-wrong.tpl new file mode 100644 index 0000000..1fa9f40 --- /dev/null +++ b/src/tpl/jinja2/tip-went-wrong.tpl @@ -0,0 +1,12 @@ +{% set user_from = a.u_from.name %} +{% if a.u_to %} +{% set user_to_fmt = "/u/%s" % a.u_to.name %} +{% else %} +{% set user_to_fmt = a.addr_to %} +{% endif %} +{% set coin_amount = a.coinval %} +{% set coin_name = ctb.conf.coins[a.coin].name %} +{% set coin_amount_fmt = "%.9f %s(s)" % (coin_amount, coin_name) %} +Hey {{ user_from | replace('_', '\_') }}, something went wrong and your tip/withdraw of **{{ coin_amount_fmt }}** to **{{ user_to_fmt }}** may not have been processed. Developer has been notified, and will look into the issue as soon as possible. + +{% include 'footer.tpl' %} diff --git a/src/tpl/jinja2/welcome.tpl b/src/tpl/jinja2/welcome.tpl new file mode 100644 index 0000000..03d9045 --- /dev/null +++ b/src/tpl/jinja2/welcome.tpl @@ -0,0 +1,8 @@ +{% set user_from = a.u_from.name %} +{% set user_bot = ctb.conf.reddit.auth.user %} +Welcome {{ user_from | replace('_', '\_') }}! + +Thank you for registering. You can now view your balances and deposit addresses with __[+info](http://www.reddit.com/message/compose?to={{ user_bot }}&subject=info&message=%2Binfo)__ command. + +For more commands and helpful information on getting started, see the footer of this message. Happy tipping! +{% include 'footer.tpl' %}