Compare commits
2 Commits
Author | SHA1 | Date |
---|---|---|
dave | 9c46b5fc7c | |
dave | 18c70b1f56 |
|
@ -1,6 +0,0 @@
|
||||||
/testenv/*
|
|
||||||
/share/*
|
|
||||||
/build/*
|
|
||||||
/dist/*
|
|
||||||
/*.egg-info
|
|
||||||
|
|
21
Dockerfile
21
Dockerfile
|
@ -1,21 +0,0 @@
|
||||||
FROM ubuntu:bionic
|
|
||||||
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y python3-pip git && \
|
|
||||||
useradd --home-dir /srv/bot bot && \
|
|
||||||
mkdir -p /srv/bot && \
|
|
||||||
chown bot /srv/bot
|
|
||||||
|
|
||||||
COPY ./requirements.txt /requirements.txt
|
|
||||||
|
|
||||||
RUN pip3 install -r /requirements.txt
|
|
||||||
|
|
||||||
COPY ./ /tmp/pyircbot
|
|
||||||
RUN cd /tmp/pyircbot && \
|
|
||||||
python3 setup.py install
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/pyircbot"]
|
|
||||||
WORKDIR /srv/bot/
|
|
||||||
CMD ["-c", "config.json"]
|
|
||||||
USER bot
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ from msgbus.client import MsgbusSubClient
|
||||||
import pyircbot
|
import pyircbot
|
||||||
import traceback
|
import traceback
|
||||||
from pyircbot.pyircbot import PrimitiveBot
|
from pyircbot.pyircbot import PrimitiveBot
|
||||||
from pyircbot.irccore import IRCEvent, UserPrefix, IRCCore
|
from pyircbot.irccore import IRCEvent, UserPrefix
|
||||||
from pyircbot.common import TouchReload
|
from pyircbot.common import TouchReload
|
||||||
from json import dumps
|
from json import dumps
|
||||||
|
|
||||||
|
@ -57,10 +57,12 @@ class PyIRCBotSub(PrimitiveBot):
|
||||||
args, sender, trailing, extras = loads(rest)
|
args, sender, trailing, extras = loads(rest)
|
||||||
nick, username, hostname = extras["prefix"]
|
nick, username, hostname = extras["prefix"]
|
||||||
|
|
||||||
msg = IRCCore.packetAsObject(command.upper(),
|
msg = IRCEvent(command.upper(),
|
||||||
args,
|
args,
|
||||||
f"{nick}!{username}@{hostname}", # hack
|
UserPrefix(nick,
|
||||||
trailing)
|
username,
|
||||||
|
hostname),
|
||||||
|
trailing)
|
||||||
|
|
||||||
for module_name, module in self.moduleInstances.items():
|
for module_name, module in self.moduleInstances.items():
|
||||||
for hook in module.irchooks:
|
for hook in module.irchooks:
|
||||||
|
@ -106,11 +108,11 @@ class PyIRCBotSub(PrimitiveBot):
|
||||||
return self
|
return self
|
||||||
return super().getBestModuleForService(service)
|
return super().getBestModuleForService(service)
|
||||||
|
|
||||||
def nick(self):
|
def get_nick(self):
|
||||||
return self.meta.get("nick", None)
|
return self.meta.get("nick", None)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
if __name__ == "__main__":
|
||||||
logging.basicConfig(level=logging.WARNING,
|
logging.basicConfig(level=logging.WARNING,
|
||||||
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
||||||
log = logging.getLogger('main')
|
log = logging.getLogger('main')
|
||||||
|
@ -158,6 +160,3 @@ def main():
|
||||||
|
|
||||||
bot.run()
|
bot.run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
|
@ -3,12 +3,12 @@ import sys
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from pyircbot.common import load, sentry_sdk
|
from pyircbot.common import load
|
||||||
from pyircbot import PyIRCBot
|
from pyircbot import PyIRCBot
|
||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
|
|
||||||
def main():
|
if __name__ == "__main__":
|
||||||
" logging level and facility "
|
" logging level and facility "
|
||||||
logging.basicConfig(level=logging.INFO,
|
logging.basicConfig(level=logging.INFO,
|
||||||
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
||||||
|
@ -33,9 +33,6 @@ def main():
|
||||||
|
|
||||||
botconfig = loads(sys.stdin.read()) if args.config == "-" else load(args.config)
|
botconfig = loads(sys.stdin.read()) if args.config == "-" else load(args.config)
|
||||||
|
|
||||||
if sentry_sdk and "dsn" in botconfig["bot"]:
|
|
||||||
sentry_sdk.init(botconfig["bot"]["dsn"])
|
|
||||||
|
|
||||||
log.debug(botconfig)
|
log.debug(botconfig)
|
||||||
|
|
||||||
bot = PyIRCBot(botconfig)
|
bot = PyIRCBot(botconfig)
|
||||||
|
@ -48,7 +45,3 @@ def main():
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
bot.run()
|
bot.run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
|
@ -2,7 +2,7 @@
|
||||||
===================================================
|
===================================================
|
||||||
|
|
||||||
A module providing a simple login/logout account service. "Trust" is based upon
|
A module providing a simple login/logout account service. "Trust" is based upon
|
||||||
hostname - logging in autorizes your current hostname for your account data,
|
hostname - logging in autorizes your current hostname for your account data,
|
||||||
which is tied to your nick.
|
which is tied to your nick.
|
||||||
|
|
||||||
Commands
|
Commands
|
||||||
|
@ -21,24 +21,6 @@ Commands
|
||||||
|
|
||||||
Log out of account (deauthorize your current hostname)
|
Log out of account (deauthorize your current hostname)
|
||||||
|
|
||||||
Utilities
|
|
||||||
---------
|
|
||||||
|
|
||||||
NickUser provides a decorator that can be used to lock module commands methods
|
|
||||||
behind a login:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from pyircbot.modulebase import ModuleBase, command
|
|
||||||
from pyircbot.modules.NickUser import protected
|
|
||||||
|
|
||||||
class MyModule(ModuleBase):
|
|
||||||
|
|
||||||
@command("foo", allow_private=True)
|
|
||||||
@protected()
|
|
||||||
def cmd_foo(self, message, command):
|
|
||||||
print(message.prefix.nick, "called foo whiled logged in!")
|
|
||||||
|
|
||||||
Class Reference
|
Class Reference
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
:mod:`StockIndex` --- DJIA and NASDAQ Quotes
|
|
||||||
============================================
|
|
||||||
|
|
||||||
This module provides quotes for the DJIA and NASDAQ indexes. It requires a free API key from
|
|
||||||
https://financialmodelingprep.com/
|
|
||||||
|
|
||||||
|
|
||||||
Commands
|
|
||||||
--------
|
|
||||||
|
|
||||||
.. cmdoption:: .djia
|
|
||||||
|
|
||||||
Display the DJIA index
|
|
||||||
|
|
||||||
.. cmdoption:: .nasdaq
|
|
||||||
|
|
||||||
Display the NASDAQ index
|
|
||||||
|
|
||||||
|
|
||||||
Config
|
|
||||||
------
|
|
||||||
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"apikey": "xxxxxxxxxxxxx",
|
|
||||||
"cache_update_interval": 600,
|
|
||||||
"cache_update_timeout": 10,
|
|
||||||
"warning_thresh": 1800
|
|
||||||
}
|
|
||||||
|
|
||||||
.. cmdoption:: apikey
|
|
||||||
|
|
||||||
API ley obtained from https://financialmodelingprep.com/
|
|
||||||
|
|
||||||
.. cmdoption:: cache_update_interval
|
|
||||||
|
|
||||||
How many seconds between fetching new index quotes from the API.
|
|
||||||
|
|
||||||
.. cmdoption:: cache_update_timeout
|
|
||||||
|
|
||||||
Maximum seconds to wait on the HTTP request sent to the API
|
|
||||||
|
|
||||||
.. cmdoption:: warning_thresh
|
|
||||||
|
|
||||||
A warning will be shown that the quote is out-of-date if the last successful fetch was longer ago than this
|
|
||||||
setting's number of seconds.
|
|
||||||
|
|
||||||
|
|
||||||
Class Reference
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: pyircbot.modules.StockIndex
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
|
@ -1,134 +0,0 @@
|
||||||
:mod:`StockPlay` --- Stock-like trading game
|
|
||||||
============================================
|
|
||||||
|
|
||||||
This module provides a simulated stock trading game. Requires and api key from
|
|
||||||
https://www.alphavantage.co/ to fetch stock quotes.
|
|
||||||
|
|
||||||
Most commands require that the player login as described in the NickUser module.
|
|
||||||
|
|
||||||
Note that it is important to configure api limitations when configuring this module. The alphavantage.co api allows a
|
|
||||||
maximum of 5 requests per minute and 500 requests per day. For reasonable trading - that is, executing trades at the
|
|
||||||
current market price - we need to be able to lookup the price of any symbol at any time. Likewise, to generate reports
|
|
||||||
we need to keep the prices of all symbols somewhat up to date. This happens at some interval - see *bginterval*.
|
|
||||||
|
|
||||||
Considering the daily limit means, when evenly spread, we can sent a request *no more often* than 173 seconds:
|
|
||||||
`(24 * 60 * 60 / 500)` - and therefore, the value of *bginterval* must be some value larger than 173, as this value will
|
|
||||||
completely consume the daily limit.
|
|
||||||
|
|
||||||
When trading, the price of the traded symbol is allowed to be *trade_cache_seconds* seconds old before the API will be
|
|
||||||
used to fetch a more recent price. This value must be balanced against *bginterval* depending on your trade frequency
|
|
||||||
and variety.
|
|
||||||
|
|
||||||
Background or batch-style tasks that rely on symbol prices run afoul with the above constraints - but in a
|
|
||||||
magnified way as they rely on api-provided data to calculate player stats across many players at a time.
|
|
||||||
|
|
||||||
|
|
||||||
Commands
|
|
||||||
--------
|
|
||||||
|
|
||||||
.. cmdoption:: .buy <amount> <symbol>
|
|
||||||
|
|
||||||
Buy some number of the specified symbol such as ".buy 10 amd"
|
|
||||||
|
|
||||||
.. cmdoption:: .sell <amount> <symbol>
|
|
||||||
|
|
||||||
Sell similar to .buy
|
|
||||||
|
|
||||||
.. cmdoption:: .port [<player>] [<full>]
|
|
||||||
|
|
||||||
Get a report on the calling player's portfolio. Another player's name can be passed as an argument to retrieve
|
|
||||||
information about a player other than the calling player. Finally, the 'full' argument can be added to retrieve a
|
|
||||||
full listing of the player's holdings.
|
|
||||||
|
|
||||||
|
|
||||||
Config
|
|
||||||
------
|
|
||||||
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
"startbalance": 10000,
|
|
||||||
"tradedelay": 0,
|
|
||||||
"trade_cache_seconds": 300,
|
|
||||||
"bginterval": 300,
|
|
||||||
"announce_trades": false,
|
|
||||||
"announce_channel": "#trades",
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"provider": "iexcloud",
|
|
||||||
"apikey": "xxxxxxxxxxxxxxx"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"provider": "alphavantage",
|
|
||||||
"apikey": "xxxxxxxxxxxxxxx"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
.. cmdoption:: startbalance
|
|
||||||
|
|
||||||
Number of dollars that players start with
|
|
||||||
|
|
||||||
.. cmdoption:: tradedelay
|
|
||||||
|
|
||||||
Delay in seconds between differing trades of the same symbol. Multiple buys OR multiple sells are allowed, but
|
|
||||||
not a mix.
|
|
||||||
|
|
||||||
NOT IMPLEMENTED
|
|
||||||
|
|
||||||
.. cmdoption:: providers
|
|
||||||
|
|
||||||
A list of providers to fetch stock data from
|
|
||||||
|
|
||||||
Supported providers:
|
|
||||||
|
|
||||||
* https://www.alphavantage.co/
|
|
||||||
* https://iexcloud.io/
|
|
||||||
|
|
||||||
.. cmdoption:: trade_cache_seconds
|
|
||||||
|
|
||||||
When performing a trade, how old of a cached symbol price is permitted before fetching from API.
|
|
||||||
|
|
||||||
Recommended ~30 minutes (1800)
|
|
||||||
|
|
||||||
.. cmdoption:: bginterval
|
|
||||||
|
|
||||||
Symbol prices are updated in the background. This is necessary because fetching a portfolio report may require
|
|
||||||
fetching many symbol prices. The alphavantage.co api allows only 5 calls per minute. Because of this limitation,
|
|
||||||
fetching a report would take multiple minutes with more than 5 symbols, which would not work.
|
|
||||||
|
|
||||||
For this reason, we update symbols at a low interval in the background. Every *bginterval* seconds, a task will be
|
|
||||||
started that updates the price of the oldest symbol.
|
|
||||||
|
|
||||||
Estimated 5 minute (300), but likely will need tuning depending on playerbase
|
|
||||||
|
|
||||||
.. cmdoption:: midnight_offset
|
|
||||||
|
|
||||||
Number of seconds **added** to the clock when calculating midnight.
|
|
||||||
|
|
||||||
At midnight, the bot logs all player balances for use in gain/loss over time calculations later on. If you want this
|
|
||||||
to happen at midnight system time, leave this at 0. Otherwise, it can be set to some number of seconds to e.g. to
|
|
||||||
compensate for time zones.
|
|
||||||
|
|
||||||
Default: 0
|
|
||||||
|
|
||||||
.. cmdoption:: announce_trades
|
|
||||||
|
|
||||||
Boolean option to announce all trades in a specific channel.
|
|
||||||
|
|
||||||
Default: false
|
|
||||||
|
|
||||||
.. cmdoption:: announce_channel
|
|
||||||
|
|
||||||
Channel name to announce all trades in.
|
|
||||||
|
|
||||||
Default: not set
|
|
||||||
|
|
||||||
|
|
||||||
Class Reference
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: pyircbot.modules.StockPlay
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
|
@ -1,14 +1,13 @@
|
||||||
FROM ubuntu:bionic
|
FROM ubuntu:artful
|
||||||
|
|
||||||
RUN apt-get update && \
|
ADD ./ /tmp/pyircbot/
|
||||||
export DEBIAN_FRONTEND=noninteractive && \
|
|
||||||
|
RUN apt-get update ; \
|
||||||
apt-get install -y python3 python3-sphinx python3-setuptools python3-dev python3-pip make wget unzip git
|
apt-get install -y python3 python3-sphinx python3-setuptools python3-dev python3-pip make wget unzip git
|
||||||
|
|
||||||
COPY ./docs/builder/start /start
|
RUN cd /tmp/pyircbot/ && pip3 install -r requirements.txt
|
||||||
|
|
||||||
COPY ./ /tmp/pyircbot/
|
COPY docs/builder/start /start
|
||||||
|
|
||||||
RUN cd /tmp/pyircbot/ && pip3 install -r requirements-test.txt
|
|
||||||
|
|
||||||
RUN chmod +x /start ; \
|
RUN chmod +x /start ; \
|
||||||
mkdir /tmp/docs
|
mkdir /tmp/docs
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#!/bin/sh -ex
|
#!/bin/sh -ex
|
||||||
|
|
||||||
docker run -it --rm -v $PWD/:/tmp/pyircbot/ pybdocbuilder bash
|
sudo docker run -it --rm -v $PWD/:/tmp/pyircbot/ pybdocbuilder bash
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
#!/bin/sh -ex
|
#!/bin/sh -ex
|
||||||
|
|
||||||
docker build -t pybdocbuilder -f docs/builder/Dockerfile .
|
docker build -t pybdocbuilder -f docs/builder/Dockerfile .
|
||||||
|
|
||||||
|
|
|
@ -2,19 +2,8 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
* :feature:`-` Added StockPlay module
|
* :release:`4.1.0 <2017-03-28>`
|
||||||
* :feature:`-` Added `@protected` decorator
|
|
||||||
|
|
||||||
* :release:`4.1.0 <2019-02-10>`
|
|
||||||
* :support:`-` First documented release in awhile. Many new modules and tests have been added. See the git log if you so desire.
|
|
||||||
* :feature:`-` Upgraded docker base image to ubuntu:bionic
|
|
||||||
* :feature:`-` Misc macOs related fixes
|
|
||||||
* :feature:`-` Misc python 3.7 related fixes
|
|
||||||
* :feature:`-` Misc fixes preventing doc building
|
|
||||||
|
|
||||||
* :release:`4.0.0 <2017-03-28>`
|
|
||||||
* :support:`-` Added a changelog
|
* :support:`-` Added a changelog
|
||||||
* :feature:`-` Dropped use of deprecated asynchat in favor of asyncio - Python 3.5+ now required.
|
* :feature:`-` Dropped use of deprecated asynchat in favor of asyncio - Python 3.5+ now required.
|
||||||
* :support:`-` Minor docs cleanup
|
* :support:`-` Minor docs cleanup
|
||||||
* :feature:`-` Upgraded docker base image to ubuntu:xenial
|
* :feature:`-` Upgraded docker base image to ubuntu:xenial
|
||||||
|
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
Contributing
|
|
||||||
============
|
|
||||||
|
|
||||||
The pull requests, bug reports, feature suggestions or even demo use cases contributed by the pyircbot community are all
|
|
||||||
highly appreciated and a valuable asset to the project.
|
|
||||||
|
|
||||||
This document outlines best practices and conditions for contributing changes pyircbot.
|
|
||||||
|
|
||||||
|
|
||||||
Sending a Pull Request
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
The pyircbot project does have a couple rules for pull requests:
|
|
||||||
|
|
||||||
|
|
||||||
Where
|
|
||||||
While pyircbot's main git repository is hosted on a private server, PRs are welcome on the
|
|
||||||
`github mirror <https://github.com/dpedu/pyircbot>`_ of the project.
|
|
||||||
|
|
||||||
|
|
||||||
Linting
|
|
||||||
Pyircbot's codebase is linted with flake8. The configuration arguments for flake8 are: ``--max-line-length 120
|
|
||||||
--ignore E402,E712,E731,E211 --select E,F``. TODO add a flake8 config file to the repo so these args are codified.
|
|
||||||
|
|
||||||
|
|
||||||
Python versions
|
|
||||||
Pyircbot supports the two latest versions of python 3. At present, this is Python 3.6 and 3.7. All changes must be
|
|
||||||
compatible with these versions. Using compatibility modules or checking the version at runtime is discouraged; the
|
|
||||||
code should be written in a fashion that is interoperable.
|
|
||||||
|
|
||||||
|
|
||||||
Dependencies
|
|
||||||
The core of pyircbot - that is, the bot runtime itself and the basic modules needed to operate it - `PingResponder`
|
|
||||||
and `Services` - have no dependencies outside python's standard library of modules. All changes must not change this
|
|
||||||
condition. The use of 3rd party dependencies in pyircbot modules is allowed, provided the versions used do not
|
|
||||||
conflict with modules already in use.
|
|
||||||
|
|
||||||
All 3rd party dependencies must be installable via Pip with no extra setup required (e.g. OS-level packages), with
|
|
||||||
some exceptions:
|
|
||||||
|
|
||||||
- Requiring ``git`` for pip links directly to repositories is permitted.
|
|
||||||
|
|
||||||
- Requiring a C/C++ compiler available on the system as well as python headers which are typically required for
|
|
||||||
libraries with C/C++ or other native language extensions is permitted. However, requiring additional headers or
|
|
||||||
shared libraries is prohibited, unless:
|
|
||||||
|
|
||||||
- Many operating systems provide a ``python3-pip`` or similar package that depends on many other packages that violate
|
|
||||||
the above rules, such as openssl headers or sqlite shared libraries. Pyircbot's preferred platform is Ubuntu Bionic,
|
|
||||||
and any dependencies of such a package are permitted.
|
|
||||||
|
|
||||||
- These rules apply to code dependencies only, and do not apply to external *services* such as MySQL.
|
|
||||||
|
|
||||||
Modules that require setup or dependencies beyond these rules will not be accepted into the core module set. The
|
|
||||||
recommended approach to use such a module is via the ``usermodules`` config directive.
|
|
||||||
|
|
||||||
No set of rules will handle all cases, and discussion or defending a change that violates these rules is encouraged.
|
|
||||||
|
|
||||||
|
|
||||||
Tests
|
|
||||||
Please consider the test suite when submitting changes affecting the core runtime and existing modules. Reduction of
|
|
||||||
coverage is acceptable, but breaking or removing tests is not. Changes submitted with updated or new tests will
|
|
||||||
be fast-tracked.
|
|
||||||
|
|
||||||
|
|
||||||
Contributors to Pyircbot
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
Any change submitted to pyircbot is highly appreciated.
|
|
||||||
|
|
||||||
Our contributors, in no particular order:
|
|
||||||
|
|
||||||
- `@ollien <https://github.com/ollien>`_ ❤️
|
|
||||||
- `@medmr1 <https://github.com/medmr1>`_ ❤️
|
|
||||||
- `@dpedu <https://github.com/dpedu>`_ ❤️
|
|
|
@ -15,7 +15,6 @@ Contents:
|
||||||
module_guide/_module_guide.rst
|
module_guide/_module_guide.rst
|
||||||
module_guide/_pubsub_mode.rst
|
module_guide/_pubsub_mode.rst
|
||||||
changelog.rst
|
changelog.rst
|
||||||
contributing.rst
|
|
||||||
|
|
||||||
More Information
|
More Information
|
||||||
================
|
================
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"startbalance": 10000,
|
|
||||||
"tradedelay": 0,
|
|
||||||
"apikey": "",
|
|
||||||
"tcachesecs": 300,
|
|
||||||
"rcachesecs": 14400,
|
|
||||||
"bginterval": 300,
|
|
||||||
"midnight_offset": 0,
|
|
||||||
"announce_trades": true,
|
|
||||||
"announce_channel": "#trades"
|
|
||||||
}
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"share": "/home/dave/Code/my/pyircbot3/share"
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
FROM ubuntu:artful
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/pyircbot"]
|
||||||
|
WORKDIR /srv/bot/
|
||||||
|
CMD ["-c", "config.json"]
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y python3 python3-setuptools python3-requests curl unzip sqlite3 && \
|
||||||
|
easy_install3 pip && \
|
||||||
|
pip3 install praw==5.0.1 pytz cherrypy twilio==6.9.0 && \
|
||||||
|
cd /tmp && \
|
||||||
|
curl -o msgbus.tar.gz 'http://gitlab.davepedu.com/dave/pymsgbus/repository/archive.tar.gz?ref=master' && \
|
||||||
|
mkdir pymsgbus && tar zxvf msgbus.tar.gz --strip-components 1 -C pymsgbus/ &&\
|
||||||
|
cd pymsgbus && \
|
||||||
|
pip3 install -r requirements.txt && \
|
||||||
|
python3 setup.py install && \
|
||||||
|
cd /tmp && \
|
||||||
|
curl -o bitcoinrpc.tar.gz https://codeload.github.com/dpedu/python-bitcoinrpc/tar.gz/master && \
|
||||||
|
tar zxvf bitcoinrpc.tar.gz && \
|
||||||
|
cd python-bitcoinrpc-master && \
|
||||||
|
python3 setup.py install && \
|
||||||
|
useradd --home-dir /srv/bot bot && \
|
||||||
|
chown bot /srv/bot && \
|
||||||
|
rm -rf /var/lib/apt/lists/* /tmp/bitcoinrpc.tar.gz /tmp/python-bitcoinrpc-master
|
||||||
|
|
||||||
|
COPY . /tmp/pyircbot/
|
||||||
|
|
||||||
|
RUN cd /tmp/pyircbot/ && \
|
||||||
|
python3 setup.py install && \
|
||||||
|
su -c "cp -r /tmp/pyircbot/examples/config.json /tmp/pyircbot/examples/data/ /srv/bot/" bot && \
|
||||||
|
cd / && \
|
||||||
|
rm -rf /tmp/pyircbot
|
||||||
|
|
||||||
|
USER bot
|
|
@ -5,20 +5,11 @@ from collections import namedtuple
|
||||||
from time import sleep
|
from time import sleep
|
||||||
import os
|
import os
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
try:
|
|
||||||
import sentry_sdk
|
|
||||||
except ImportError:
|
|
||||||
sentry_sdk = None
|
|
||||||
|
|
||||||
|
|
||||||
ParsedCommand = namedtuple("ParsedCommand", "command args args_str message")
|
ParsedCommand = namedtuple("ParsedCommand", "command args args_str message")
|
||||||
|
|
||||||
|
|
||||||
def report(exception):
|
|
||||||
if sentry_sdk:
|
|
||||||
sentry_sdk.capture_exception(exception)
|
|
||||||
|
|
||||||
|
|
||||||
class burstbucket(object):
|
class burstbucket(object):
|
||||||
def __init__(self, maximum, interval):
|
def __init__(self, maximum, interval):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -12,13 +12,11 @@ import logging
|
||||||
import traceback
|
import traceback
|
||||||
import sys
|
import sys
|
||||||
from inspect import getargspec
|
from inspect import getargspec
|
||||||
from pyircbot.common import burstbucket, parse_irc_line, report
|
from pyircbot.common import burstbucket, parse_irc_line
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from time import time
|
|
||||||
|
|
||||||
|
IRCEvent = namedtuple("IRCEvent", "command args prefix trailing")
|
||||||
IRCEvent = namedtuple("IRCEvent", "command args prefix trailing replyto")
|
|
||||||
UserPrefix = namedtuple("UserPrefix", "nick username hostname")
|
UserPrefix = namedtuple("UserPrefix", "nick username hostname")
|
||||||
ServerPrefix = namedtuple("ServerPrefix", "hostname")
|
ServerPrefix = namedtuple("ServerPrefix", "hostname")
|
||||||
|
|
||||||
|
@ -80,10 +78,9 @@ class IRCCore(object):
|
||||||
family=self.connection_family,
|
family=self.connection_family,
|
||||||
local_addr=self.bind_addr)
|
local_addr=self.bind_addr)
|
||||||
self.fire_hook("_CONNECT")
|
self.fire_hook("_CONNECT")
|
||||||
except (socket.gaierror, ConnectionRefusedError, OSError) as e:
|
except (socket.gaierror, ConnectionRefusedError):
|
||||||
|
traceback.print_exc()
|
||||||
logging.warning("Non-fatal connect error, trying next server...")
|
logging.warning("Non-fatal connect error, trying next server...")
|
||||||
self.trace()
|
|
||||||
report(e)
|
|
||||||
self.server = (self.server + 1) % len(self.servers)
|
self.server = (self.server + 1) % len(self.servers)
|
||||||
await asyncio.sleep(1, loop=loop)
|
await asyncio.sleep(1, loop=loop)
|
||||||
continue
|
continue
|
||||||
|
@ -98,13 +95,11 @@ class IRCCore(object):
|
||||||
.format(command, prefix, args, trailing))
|
.format(command, prefix, args, trailing))
|
||||||
else:
|
else:
|
||||||
self.fire_hook(command, args=args, prefix=prefix, trailing=trailing)
|
self.fire_hook(command, args=args, prefix=prefix, trailing=trailing)
|
||||||
except (ConnectionResetError, asyncio.streams.IncompleteReadError) as e:
|
except (ConnectionResetError, asyncio.streams.IncompleteReadError):
|
||||||
self.trace()
|
traceback.print_exc()
|
||||||
report(e)
|
|
||||||
break
|
break
|
||||||
except (UnicodeDecodeError, ) as e:
|
except (UnicodeDecodeError, ):
|
||||||
self.trace()
|
traceback.print_exc()
|
||||||
report(e)
|
|
||||||
self.fire_hook("_DISCONNECT")
|
self.fire_hook("_DISCONNECT")
|
||||||
self.writer.close()
|
self.writer.close()
|
||||||
if self.alive:
|
if self.alive:
|
||||||
|
@ -115,8 +110,8 @@ class IRCCore(object):
|
||||||
async def outputqueue(self):
|
async def outputqueue(self):
|
||||||
self.bucket = burstbucket(self.rate_max, self.rate_int)
|
self.bucket = burstbucket(self.rate_max, self.rate_int)
|
||||||
while True:
|
while True:
|
||||||
|
prio, line = await self.outputq.get()
|
||||||
# sleep until the bucket allows us to send
|
# sleep until the bucket allows us to send
|
||||||
# TODO warn/drop option if age (the _ above is older than some threshold)
|
|
||||||
if self.rate_limit:
|
if self.rate_limit:
|
||||||
while True:
|
while True:
|
||||||
s = self.bucket.get()
|
s = self.bucket.get()
|
||||||
|
@ -124,15 +119,14 @@ class IRCCore(object):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
await asyncio.sleep(s, loop=self._loop)
|
await asyncio.sleep(s, loop=self._loop)
|
||||||
prio, _, line = await self.outputq.get()
|
|
||||||
self.fire_hook('_SEND', args=None, prefix=None, trailing=None)
|
self.fire_hook('_SEND', args=None, prefix=None, trailing=None)
|
||||||
self.log.debug(">>> {}".format(repr(line)))
|
self.log.debug(">>> {}".format(repr(line)))
|
||||||
self.outputq.task_done()
|
self.outputq.task_done()
|
||||||
try:
|
try:
|
||||||
self.writer.write((line + "\r\n").encode("UTF-8"))
|
self.writer.write((line + "\r\n").encode("UTF-8"))
|
||||||
except Exception as e: # Probably fine if we drop messages while offline
|
except Exception as e: # Probably fine if we drop messages while offline
|
||||||
self.trace()
|
print(e)
|
||||||
report(e)
|
print(self.trace())
|
||||||
|
|
||||||
async def kill(self, message="Help! Another thread is killing me :(", forever=True):
|
async def kill(self, message="Help! Another thread is killing me :(", forever=True):
|
||||||
"""Send quit message, flush queue, and close the socket
|
"""Send quit message, flush queue, and close the socket
|
||||||
|
@ -158,7 +152,7 @@ class IRCCore(object):
|
||||||
if priority is None:
|
if priority is None:
|
||||||
self.outseq += 1
|
self.outseq += 1
|
||||||
priority = self.outseq
|
priority = self.outseq
|
||||||
asyncio.run_coroutine_threadsafe(self.outputq.put((priority, time(), data, )), self._loop)
|
asyncio.run_coroutine_threadsafe(self.outputq.put((priority, data, )), self._loop)
|
||||||
|
|
||||||
" Module related code "
|
" Module related code "
|
||||||
def initHooks(self):
|
def initHooks(self):
|
||||||
|
@ -224,9 +218,8 @@ class IRCCore(object):
|
||||||
else:
|
else:
|
||||||
hook(args, prefix, trailing)
|
hook(args, prefix, trailing)
|
||||||
|
|
||||||
except Exception as e:
|
except:
|
||||||
self.log.warning("Error processing hook: \n%s" % self.trace())
|
self.log.warning("Error processing hook: \n%s" % self.trace())
|
||||||
report(e)
|
|
||||||
|
|
||||||
def addHook(self, command, method):
|
def addHook(self, command, method):
|
||||||
"""**Internal.** Enable (connect) a single hook of a module
|
"""**Internal.** Enable (connect) a single hook of a module
|
||||||
|
@ -258,7 +251,6 @@ class IRCCore(object):
|
||||||
self.log.warning("Invalid hook - %s" % command)
|
self.log.warning("Invalid hook - %s" % command)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def packetAsObject(command, args, prefix, trailing):
|
def packetAsObject(command, args, prefix, trailing):
|
||||||
"""Given an irc message's args, prefix, and trailing data return an object with these properties
|
"""Given an irc message's args, prefix, and trailing data return an object with these properties
|
||||||
|
|
||||||
|
@ -270,15 +262,9 @@ class IRCCore(object):
|
||||||
:type trailing: str
|
:type trailing: str
|
||||||
:returns: object -- a IRCEvent object with the ``args``, ``prefix``, ``trailing``"""
|
:returns: object -- a IRCEvent object with the ``args``, ``prefix``, ``trailing``"""
|
||||||
|
|
||||||
prefix = IRCCore.decodePrefix(prefix) if prefix else None
|
return IRCEvent(command, args,
|
||||||
|
IRCCore.decodePrefix(prefix) if prefix else None,
|
||||||
replyto = None
|
trailing)
|
||||||
if command == "PRIVMSG":
|
|
||||||
# prefix will always be set for PRIVMSG
|
|
||||||
# TODO server side fuzzing
|
|
||||||
replyto = args[0] if args[0].startswith("#") else prefix.nick
|
|
||||||
|
|
||||||
return IRCEvent(command, args, prefix, trailing, replyto)
|
|
||||||
|
|
||||||
" Utility methods "
|
" Utility methods "
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -327,12 +313,12 @@ class IRCCore(object):
|
||||||
return self.nick
|
return self.nick
|
||||||
|
|
||||||
" Action Methods "
|
" Action Methods "
|
||||||
def act_PONG(self, data, priority=1):
|
def act_PONG(self, data):
|
||||||
"""Use the `/pong` command - respond to server pings
|
"""Use the `/pong` command - respond to server pings
|
||||||
|
|
||||||
:param data: the string or number the server sent with it's ping
|
:param data: the string or number the server sent with it's ping
|
||||||
:type data: str"""
|
:type data: str"""
|
||||||
self.sendRaw("PONG :%s" % data, priority)
|
self.sendRaw("PONG :%s" % data)
|
||||||
|
|
||||||
def act_USER(self, username, hostname, realname, priority=2):
|
def act_USER(self, username, hostname, realname, priority=2):
|
||||||
"""Use the USER protocol command. Used during connection
|
"""Use the USER protocol command. Used during connection
|
||||||
|
@ -358,18 +344,18 @@ class IRCCore(object):
|
||||||
|
|
||||||
:param channel: the channel to attempt to join
|
:param channel: the channel to attempt to join
|
||||||
:type channel: str"""
|
:type channel: str"""
|
||||||
self.sendRaw("JOIN %s" % channel, priority)
|
self.sendRaw("JOIN %s" % channel, priority=3)
|
||||||
|
|
||||||
def act_PRIVMSG(self, towho, message, priority=3):
|
def act_PRIVMSG(self, towho, message):
|
||||||
"""Use the `/msg` command
|
"""Use the `/msg` command
|
||||||
|
|
||||||
:param towho: the target #channel or user's name
|
:param towho: the target #channel or user's name
|
||||||
:type towho: str
|
:type towho: str
|
||||||
:param message: the message to send
|
:param message: the message to send
|
||||||
:type message: str"""
|
:type message: str"""
|
||||||
self.sendRaw("PRIVMSG %s :%s" % (towho, message), priority)
|
self.sendRaw("PRIVMSG %s :%s" % (towho, message))
|
||||||
|
|
||||||
def act_MODE(self, channel, mode, extra=None, priority=2):
|
def act_MODE(self, channel, mode, extra=None):
|
||||||
"""Use the `/mode` command
|
"""Use the `/mode` command
|
||||||
|
|
||||||
:param channel: the channel this mode is for
|
:param channel: the channel this mode is for
|
||||||
|
@ -379,20 +365,20 @@ class IRCCore(object):
|
||||||
:param extra: additional argument if the mode needs it. Example: user@*!*
|
:param extra: additional argument if the mode needs it. Example: user@*!*
|
||||||
:type extra: str"""
|
:type extra: str"""
|
||||||
if extra is not None:
|
if extra is not None:
|
||||||
self.sendRaw("MODE %s %s %s" % (channel, mode, extra), priority)
|
self.sendRaw("MODE %s %s %s" % (channel, mode, extra))
|
||||||
else:
|
else:
|
||||||
self.sendRaw("MODE %s %s" % (channel, mode), priority)
|
self.sendRaw("MODE %s %s" % (channel, mode))
|
||||||
|
|
||||||
def act_ACTION(self, channel, action, priority=2):
|
def act_ACTION(self, channel, action):
|
||||||
"""Use the `/me <action>` command
|
"""Use the `/me <action>` command
|
||||||
|
|
||||||
:param channel: the channel name or target's name the message is sent to
|
:param channel: the channel name or target's name the message is sent to
|
||||||
:type channel: str
|
:type channel: str
|
||||||
:param action: the text to send
|
:param action: the text to send
|
||||||
:type action: str"""
|
:type action: str"""
|
||||||
self.sendRaw("PRIVMSG %s :\x01ACTION %s" % (channel, action), priority)
|
self.sendRaw("PRIVMSG %s :\x01ACTION %s" % (channel, action))
|
||||||
|
|
||||||
def act_KICK(self, channel, who, comment="", priority=2):
|
def act_KICK(self, channel, who, comment=""):
|
||||||
"""Use the `/kick <user> <message>` command
|
"""Use the `/kick <user> <message>` command
|
||||||
|
|
||||||
:param channel: the channel from which the user will be kicked
|
:param channel: the channel from which the user will be kicked
|
||||||
|
@ -401,7 +387,7 @@ class IRCCore(object):
|
||||||
:type action: str
|
:type action: str
|
||||||
:param comment: the kick message
|
:param comment: the kick message
|
||||||
:type comment: str"""
|
:type comment: str"""
|
||||||
self.sendRaw("KICK %s %s :%s" % (channel, who, comment), priority)
|
self.sendRaw("KICK %s %s :%s" % (channel, who, comment))
|
||||||
|
|
||||||
def act_QUIT(self, message, priority=2):
|
def act_QUIT(self, message, priority=2):
|
||||||
"""Use the `/quit` command
|
"""Use the `/quit` command
|
||||||
|
@ -410,8 +396,8 @@ class IRCCore(object):
|
||||||
:type message: str"""
|
:type message: str"""
|
||||||
self.sendRaw("QUIT :%s" % message, priority)
|
self.sendRaw("QUIT :%s" % message, priority)
|
||||||
|
|
||||||
def act_PASS(self, password, priority=1):
|
def act_PASS(self, password):
|
||||||
"""
|
"""
|
||||||
Send server password, for use on connection
|
Send server password, for use on connection
|
||||||
"""
|
"""
|
||||||
self.sendRaw("PASS %s" % password, priority)
|
self.sendRaw("PASS %s" % password)
|
||||||
|
|
|
@ -96,8 +96,9 @@ class RecieveGenerator(object):
|
||||||
print("total", total, "expected", self.length)
|
print("total", total, "expected", self.length)
|
||||||
if total != self.length:
|
if total != self.length:
|
||||||
raise TransferFailedException("Transfer failed: expected {} bytes but got {}".format(self.length, total))
|
raise TransferFailedException("Transfer failed: expected {} bytes but got {}".format(self.length, total))
|
||||||
|
raise StopIteration()
|
||||||
finally:
|
finally:
|
||||||
# self.sock.shutdown(socket.SHUT_RDWR)
|
self.sock.shutdown(socket.SHUT_RDWR)
|
||||||
self.sock.close()
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
@ -139,11 +140,8 @@ class OfferThread(Thread):
|
||||||
clientsocket.shutdown(socket.SHUT_RDWR)
|
clientsocket.shutdown(socket.SHUT_RDWR)
|
||||||
clientsocket.close()
|
clientsocket.close()
|
||||||
finally:
|
finally:
|
||||||
# try:
|
self.listener.shutdown(socket.SHUT_RDWR)
|
||||||
# self.listener.shutdown(socket.SHUT_RDWR)
|
|
||||||
self.listener.close()
|
self.listener.close()
|
||||||
# except Exception:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
def abort(self):
|
def abort(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -7,16 +7,23 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pyircbot.modulebase import ModuleBase, hook
|
from pyircbot.modulebase import ModuleBase, ModuleHook
|
||||||
|
|
||||||
|
|
||||||
class Error(ModuleBase):
|
class Error(ModuleBase):
|
||||||
|
def __init__(self, bot, moduleName):
|
||||||
|
ModuleBase.__init__(self, bot, moduleName)
|
||||||
|
self.hooks = [ModuleHook("PRIVMSG", self.error)]
|
||||||
|
|
||||||
@hook("PRIVMSG")
|
def error(self, args, prefix, trailing):
|
||||||
def error(self, message, command):
|
"""If the message recieved from IRC has the string "error" in it, cause a ZeroDivisionError
|
||||||
"""
|
|
||||||
If the message recieved from IRC has the string "error" in it, cause a ZeroDivisionError
|
:param args: IRC args received
|
||||||
"""
|
:type args: list
|
||||||
if "error" in message.trailing:
|
:param prefix: IRC prefix of sender
|
||||||
|
:type prefix: str
|
||||||
|
:param trailing: IRC message body
|
||||||
|
:type trailing: str"""
|
||||||
|
if "error" in trailing:
|
||||||
print(10 / 0)
|
print(10 / 0)
|
||||||
|
|
||||||
|
|
|
@ -72,6 +72,7 @@ class ModInfo(ModuleBase):
|
||||||
# Find widest value per col
|
# Find widest value per col
|
||||||
for row in rows:
|
for row in rows:
|
||||||
for col, value in enumerate(row):
|
for col, value in enumerate(row):
|
||||||
|
print(col, value)
|
||||||
vlen = len(value)
|
vlen = len(value)
|
||||||
if vlen > widths[col]:
|
if vlen > widths[col]:
|
||||||
widths[col] = vlen
|
widths[col] = vlen
|
||||||
|
@ -108,3 +109,4 @@ class ModInfo(ModuleBase):
|
||||||
if callable(attr) and hasattr(attr, "irchelp"):
|
if callable(attr) and hasattr(attr, "irchelp"):
|
||||||
for cmdinfo in attr.irchelp:
|
for cmdinfo in attr.irchelp:
|
||||||
yield (modname, module, cmdinfo.cmdspec, cmdinfo.docstring, cmdinfo.aliases)
|
yield (modname, module, cmdinfo.cmdspec, cmdinfo.docstring, cmdinfo.aliases)
|
||||||
|
raise StopIteration()
|
||||||
|
|
|
@ -50,16 +50,13 @@ class NickUser(ModuleBase):
|
||||||
oldpass = attr.getKey(prefix.nick, "password")
|
oldpass = attr.getKey(prefix.nick, "password")
|
||||||
if oldpass is None:
|
if oldpass is None:
|
||||||
attr.setKey(prefix.nick, "password", cmd.args[0])
|
attr.setKey(prefix.nick, "password", cmd.args[0])
|
||||||
attr.setKey(prefix.nick, "loggedinfrom", prefix.hostname)
|
self.bot.act_PRIVMSG(prefix.nick, ".setpass: Your password has been set to \"%s\"." % cmd.args[0])
|
||||||
self.bot.act_PRIVMSG(prefix.nick, ".setpass: You've been logged in and "
|
|
||||||
"your password has been set to \"%s\"." % cmd.args[0])
|
|
||||||
else:
|
else:
|
||||||
if len(cmd.args) == 2:
|
if len(cmd.args) == 2:
|
||||||
if cmd.args[0] == oldpass:
|
if cmd.args[0] == oldpass:
|
||||||
attr.setKey(prefix.nick, "password", cmd.args[1])
|
attr.setKey(prefix.nick, "password", cmd.args[1])
|
||||||
self.bot.act_PRIVMSG(prefix.nick,
|
self.bot.act_PRIVMSG(prefix.nick, ".setpass: Your password has been set to \"%s\"." %
|
||||||
".setpass: Your password has been set to \"%s\"." % cmd.args[1])
|
cmd.args[1])
|
||||||
attr.setKey(prefix.nick, "loggedinfrom", prefix.hostname)
|
|
||||||
else:
|
else:
|
||||||
self.bot.act_PRIVMSG(prefix.nick, ".setpass: Old password incorrect.")
|
self.bot.act_PRIVMSG(prefix.nick, ".setpass: Old password incorrect.")
|
||||||
else:
|
else:
|
||||||
|
@ -75,8 +72,10 @@ class NickUser(ModuleBase):
|
||||||
else:
|
else:
|
||||||
if len(cmd.args) == 1:
|
if len(cmd.args) == 1:
|
||||||
if userpw == cmd.args[0]:
|
if userpw == cmd.args[0]:
|
||||||
|
#################
|
||||||
attr.setKey(prefix.nick, "loggedinfrom", prefix.hostname)
|
attr.setKey(prefix.nick, "loggedinfrom", prefix.hostname)
|
||||||
self.bot.act_PRIVMSG(prefix.nick, ".login: You have been logged in from: %s" % prefix.hostname)
|
self.bot.act_PRIVMSG(prefix.nick, ".login: You have been logged in from: %s" % prefix.hostname)
|
||||||
|
#################
|
||||||
else:
|
else:
|
||||||
self.bot.act_PRIVMSG(prefix.nick, ".login: incorrect password.")
|
self.bot.act_PRIVMSG(prefix.nick, ".login: incorrect password.")
|
||||||
else:
|
else:
|
||||||
|
@ -90,23 +89,3 @@ class NickUser(ModuleBase):
|
||||||
else:
|
else:
|
||||||
attr.setKey(prefix.nick, "loggedinfrom", None)
|
attr.setKey(prefix.nick, "loggedinfrom", None)
|
||||||
self.bot.act_PRIVMSG(prefix.nick, ".logout: You have been logged out.")
|
self.bot.act_PRIVMSG(prefix.nick, ".logout: You have been logged out.")
|
||||||
|
|
||||||
|
|
||||||
# Decorator for methods that require login
|
|
||||||
# Assumes your args matches the same format that @command(...) expects
|
|
||||||
class protected(object):
|
|
||||||
def __init__(self, message=None):
|
|
||||||
self.message = message or "{}: you need to .login to do that"
|
|
||||||
|
|
||||||
def __call__(self, func):
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
module, message, command = args
|
|
||||||
login = module.bot.getBestModuleForService("login")
|
|
||||||
|
|
||||||
if not login.check(message.prefix.nick, message.prefix.hostname):
|
|
||||||
module.bot.act_PRIVMSG(message.args[0] if message.args[0].startswith("#") else message.prefix.nick,
|
|
||||||
self.message.format(message.prefix.nick))
|
|
||||||
return
|
|
||||||
|
|
||||||
func(*args, **kwargs)
|
|
||||||
return wrapper
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ class PingResponder(ModuleBase):
|
||||||
"""Respond to the PING command"""
|
"""Respond to the PING command"""
|
||||||
# got a ping? send it right back
|
# got a ping? send it right back
|
||||||
self.bot.act_PONG(msg.trailing)
|
self.bot.act_PONG(msg.trailing)
|
||||||
self.log.debug("%s Responded to a ping: %s" % (self.bot.get_nick(), msg.trailing))
|
self.log.info("%s Responded to a ping: %s" % (self.bot.get_nick(), msg.trailing))
|
||||||
|
|
||||||
@hook("_RECV", "_SEND")
|
@hook("_RECV", "_SEND")
|
||||||
def resettimer(self, msg, cmd):
|
def resettimer(self, msg, cmd):
|
||||||
|
|
|
@ -1,86 +0,0 @@
|
||||||
from pyircbot.modulebase import ModuleBase, command
|
|
||||||
from pyircbot.modules.ModInfo import info
|
|
||||||
from threading import Thread
|
|
||||||
from time import sleep, time
|
|
||||||
import requests
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
|
|
||||||
API_URL = "https://financialmodelingprep.com/api/v3/quotes/index?apikey={apikey}"
|
|
||||||
|
|
||||||
|
|
||||||
def bits(is_gain):
|
|
||||||
if is_gain:
|
|
||||||
return ("\x0303", "⬆", "+", )
|
|
||||||
return ("\x0304", "⬇", "-", )
|
|
||||||
|
|
||||||
|
|
||||||
class StockIndex(ModuleBase):
|
|
||||||
def __init__(self, bot, moduleName):
|
|
||||||
super().__init__(bot, moduleName)
|
|
||||||
self.session = requests.session()
|
|
||||||
self.updater = None
|
|
||||||
self.running = True
|
|
||||||
self.last_update = 0
|
|
||||||
self.start_cache_updater()
|
|
||||||
|
|
||||||
def start_cache_updater(self):
|
|
||||||
self.updater = Thread(target=self.cache_updater, daemon=True)
|
|
||||||
self.updater.start()
|
|
||||||
|
|
||||||
def ondisable(self):
|
|
||||||
self.running = False
|
|
||||||
self.updater.join()
|
|
||||||
|
|
||||||
def cache_updater(self):
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
self.update_cache()
|
|
||||||
except:
|
|
||||||
traceback.print_exc()
|
|
||||||
delay = self.config.get("cache_update_interval", 600)
|
|
||||||
while self.running and delay > 0:
|
|
||||||
delay -= 1
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
def update_cache(self):
|
|
||||||
data = self.session.get(API_URL.format(**self.config),
|
|
||||||
timeout=self.config.get("cache_update_timeout", 10)).json()
|
|
||||||
self.cache = {item["symbol"]: item for item in data}
|
|
||||||
self.last_update = time()
|
|
||||||
|
|
||||||
@info("djia", "get the current value of the DJIA", cmds=["djia"])
|
|
||||||
@command("djia", allow_private=True)
|
|
||||||
def cmd_djia(self, message, command):
|
|
||||||
self.send_quote("^DJI", "DJIA", message.replyto)
|
|
||||||
|
|
||||||
@info("nasdaq", "get the current value of the NASDAQ composite index", cmds=["nasdaq"])
|
|
||||||
@command("nasdaq", allow_private=True)
|
|
||||||
def cmd_nasdaq(self, message, command):
|
|
||||||
self.send_quote("^IXIC", "NASDAQ", message.replyto)
|
|
||||||
|
|
||||||
@info("vix", "get the current value of the vix/fear index", cmds=["vix"])
|
|
||||||
@command("vix", allow_private=True)
|
|
||||||
def cmd_vix(self, message, command):
|
|
||||||
self.send_quote("^VIX", "VIX", message.replyto)
|
|
||||||
|
|
||||||
def send_quote(self, key, symbol, to):
|
|
||||||
index = self.cache[key]
|
|
||||||
is_gain = index["price"] >= index["previousClose"]
|
|
||||||
color, arrow, plusmin = bits(is_gain)
|
|
||||||
|
|
||||||
change = float(index["price"]) - float(index["previousClose"])
|
|
||||||
percentchange = float(change) / float(index["previousClose"]) * 100
|
|
||||||
|
|
||||||
warn_thresh = self.config.get("warning_thresh", 1800)
|
|
||||||
|
|
||||||
warning = "" if time() - self.last_update < warn_thresh else " \x030(quote is out-of-date)"
|
|
||||||
|
|
||||||
self.bot.act_PRIVMSG(to, "{} ${:,.2f} {}{}{:,.2f} ({:.2f}%){}{}".format(symbol,
|
|
||||||
index["price"],
|
|
||||||
color,
|
|
||||||
plusmin,
|
|
||||||
change,
|
|
||||||
percentchange,
|
|
||||||
arrow,
|
|
||||||
warning))
|
|
|
@ -1,816 +0,0 @@
|
||||||
from pyircbot.modulebase import ModuleBase, MissingDependancyException, command
|
|
||||||
from pyircbot.modules.ModInfo import info
|
|
||||||
from pyircbot.modules.NickUser import protected
|
|
||||||
from contextlib import closing
|
|
||||||
from decimal import Decimal
|
|
||||||
from time import sleep, time
|
|
||||||
from queue import Queue, Empty
|
|
||||||
from threading import Thread
|
|
||||||
from requests import get
|
|
||||||
from collections import namedtuple
|
|
||||||
from math import ceil, floor
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
|
|
||||||
RE_SYMBOL = re.compile(r'^([A-Z\-]+)$')
|
|
||||||
DUSTACCT = "#dust"
|
|
||||||
|
|
||||||
Trade = namedtuple("Trade", "nick buy symbol amount replyto")
|
|
||||||
|
|
||||||
|
|
||||||
def tabulate(rows, justify=None):
|
|
||||||
"""
|
|
||||||
:param rows: list of lists making up the table data
|
|
||||||
:param justify: array of True/False to enable left justification of text
|
|
||||||
"""
|
|
||||||
colwidths = [0] * len(rows[0])
|
|
||||||
justify = justify or [False] * len(rows[0])
|
|
||||||
for row in rows:
|
|
||||||
for col, value in enumerate(row):
|
|
||||||
colwidths[col] = max(colwidths[col], len(str(value)))
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
yield " ".join([("{: <{}}" if justify[coli] else "{: >{}}")
|
|
||||||
.format(value, colwidths[coli]) for coli, value in enumerate(row)])
|
|
||||||
|
|
||||||
|
|
||||||
def format_price(cents, prefix="$", plus=False):
|
|
||||||
"""
|
|
||||||
Formats cents as a dollar value
|
|
||||||
"""
|
|
||||||
return format_decimal((Decimal(cents) / 100) if cents > 0 else 0, # avoids "-0.00" output
|
|
||||||
prefix, plus)
|
|
||||||
|
|
||||||
|
|
||||||
def format_decimal(decm, prefix="$", plus=False):
|
|
||||||
"""
|
|
||||||
Formats a decimal as a dollar value
|
|
||||||
"""
|
|
||||||
return "{}{}{:,.2f}".format(prefix, "+" if plus and decm >= 0 else "", decm)
|
|
||||||
|
|
||||||
|
|
||||||
def format_gainloss(diff, pct):
|
|
||||||
"""
|
|
||||||
Formats a difference and percent change as "+0.00 (0.52%)⬆" with appropriate IRC colors
|
|
||||||
"""
|
|
||||||
return ' '.join(format_gainloss_inner(diff, pct))
|
|
||||||
|
|
||||||
|
|
||||||
def format_gainloss_inner(diff, pct):
|
|
||||||
"""
|
|
||||||
Formats a difference and percent change as "+0.00 (0.52%)⬆" with appropriate IRC colors
|
|
||||||
"""
|
|
||||||
profit = diff >= 0
|
|
||||||
return "{}{}".format("\x0303" if profit else "\x0304", # green or red
|
|
||||||
format_decimal(diff, prefix="", plus=True)), \
|
|
||||||
"({:,.2f}%){}\x0f".format(pct * 100,
|
|
||||||
"⬆" if profit else "⬇")
|
|
||||||
|
|
||||||
|
|
||||||
def calc_gain(start, end):
|
|
||||||
"""
|
|
||||||
Calculate the +/- gain percent given start/end values
|
|
||||||
:return: Decimal
|
|
||||||
"""
|
|
||||||
if not start:
|
|
||||||
return Decimal(0)
|
|
||||||
gain_value = end - start
|
|
||||||
return Decimal(gain_value) / Decimal(start)
|
|
||||||
|
|
||||||
|
|
||||||
class StockPlay(ModuleBase):
|
|
||||||
def __init__(self, bot, moduleName):
|
|
||||||
ModuleBase.__init__(self, bot, moduleName)
|
|
||||||
self.sqlite = self.bot.getBestModuleForService("sqlite")
|
|
||||||
|
|
||||||
if self.sqlite is None:
|
|
||||||
raise MissingDependancyException("StockPlay: SQLIite service is required.")
|
|
||||||
|
|
||||||
self.sql = self.sqlite.opendb("stockplay.db")
|
|
||||||
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
if not self.sql.tableExists("stockplay_balances"):
|
|
||||||
c.execute("""CREATE TABLE `stockplay_balances` (
|
|
||||||
`nick` varchar(64) PRIMARY KEY,
|
|
||||||
`cents` integer
|
|
||||||
);""")
|
|
||||||
c.execute("""INSERT INTO `stockplay_balances` VALUES (?, ?)""", (DUSTACCT, 0))
|
|
||||||
if not self.sql.tableExists("stockplay_holdings"):
|
|
||||||
c.execute("""CREATE TABLE `stockplay_holdings` (
|
|
||||||
`nick` varchar(64),
|
|
||||||
`symbol` varchar(12),
|
|
||||||
`count` integer,
|
|
||||||
PRIMARY KEY (nick, symbol)
|
|
||||||
);""")
|
|
||||||
if not self.sql.tableExists("stockplay_trades"):
|
|
||||||
c.execute("""CREATE TABLE `stockplay_trades` (
|
|
||||||
`nick` varchar(64),
|
|
||||||
`time` integer,
|
|
||||||
`type` varchar(8),
|
|
||||||
`symbol` varchar(12),
|
|
||||||
`count` integer,
|
|
||||||
`price` integer,
|
|
||||||
`quoteprice` varchar(12)
|
|
||||||
);""")
|
|
||||||
if not self.sql.tableExists("stockplay_prices"):
|
|
||||||
c.execute("""CREATE TABLE `stockplay_prices` (
|
|
||||||
`symbol` varchar(12) PRIMARY KEY,
|
|
||||||
`time` integer,
|
|
||||||
`attempt_time` integer,
|
|
||||||
`data` text
|
|
||||||
);""")
|
|
||||||
if not self.sql.tableExists("stockplay_balance_history"):
|
|
||||||
c.execute("""CREATE TABLE `stockplay_balance_history` (
|
|
||||||
`nick` varchar(64),
|
|
||||||
`day` text,
|
|
||||||
`cents` integer,
|
|
||||||
PRIMARY KEY(nick, day)
|
|
||||||
);""")
|
|
||||||
# if not self.sql.tableExists("stockplay_report_cache"):
|
|
||||||
# c.execute("""CREATE TABLE `stockplay_report_cache` (
|
|
||||||
# `nick` varchar(64) PRIMARY KEY,
|
|
||||||
# `time` integer,
|
|
||||||
# `data` text
|
|
||||||
# );""")
|
|
||||||
|
|
||||||
self.cache = PriceCache(self)
|
|
||||||
|
|
||||||
# Last time the interval tasks were executed
|
|
||||||
self.task_time = 0
|
|
||||||
|
|
||||||
# background work executor thread
|
|
||||||
self.asyncq = Queue()
|
|
||||||
self.running = True
|
|
||||||
|
|
||||||
self.trader = Thread(target=self.trader_background)
|
|
||||||
self.trader.start()
|
|
||||||
|
|
||||||
# quote updater thread
|
|
||||||
self.pricer = Thread(target=self.price_updater)
|
|
||||||
self.pricer.start()
|
|
||||||
|
|
||||||
def ondisable(self):
|
|
||||||
self.running = False
|
|
||||||
self.trader.join()
|
|
||||||
self.pricer.join()
|
|
||||||
|
|
||||||
def calc_user_avgbuy(self, nick, symbol):
|
|
||||||
"""
|
|
||||||
Calculate the average buy price of a user's stock. This is generated by backtracking through their
|
|
||||||
buy/sell history
|
|
||||||
:return: price, in cents
|
|
||||||
"""
|
|
||||||
target_count = self.get_holding(nick, symbol) # backtrack until we hit this many shares
|
|
||||||
spent = 0
|
|
||||||
count = 0
|
|
||||||
buys = 0
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
for row in c.execute("SELECT * FROM stockplay_trades WHERE nick=? AND symbol=? ORDER BY time DESC",
|
|
||||||
(nick, symbol)).fetchall():
|
|
||||||
if row["type"] == "buy":
|
|
||||||
count += row["count"]
|
|
||||||
spent += row["price"]
|
|
||||||
buys += row["count"]
|
|
||||||
else:
|
|
||||||
count -= row["count"]
|
|
||||||
|
|
||||||
if count == target_count: # at this point in history the user held 0 of the symbol, stop backtracking
|
|
||||||
break
|
|
||||||
if not count:
|
|
||||||
return Decimal(0)
|
|
||||||
return Decimal(spent) / 100 / Decimal(buys)
|
|
||||||
|
|
||||||
def price_updater(self):
|
|
||||||
"""
|
|
||||||
Perform quote cache updating task
|
|
||||||
"""
|
|
||||||
while self.running:
|
|
||||||
self.log.info("price_updater")
|
|
||||||
try:
|
|
||||||
updatesym = None
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
row = c.execute("""SELECT * FROM stockplay_prices
|
|
||||||
WHERE symbol in (SELECT symbol FROM stockplay_holdings WHERE count>0)
|
|
||||||
ORDER BY attempt_time ASC LIMIT 1""").fetchone()
|
|
||||||
updatesym = row["symbol"] if row else None
|
|
||||||
c.execute("UPDATE stockplay_prices SET attempt_time=? WHERE symbol=?;", (time(), updatesym))
|
|
||||||
|
|
||||||
if updatesym:
|
|
||||||
self.cache.get_price(updatesym, 0)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
traceback.print_exc()
|
|
||||||
delay = self.config["bginterval"]
|
|
||||||
while self.running and delay > 0:
|
|
||||||
delay -= 1
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
def trader_background(self):
|
|
||||||
"""
|
|
||||||
Perform trading, reporting and other background tasks
|
|
||||||
"""
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
queued = None
|
|
||||||
try:
|
|
||||||
queued = self.asyncq.get(block=True, timeout=1)
|
|
||||||
except Empty:
|
|
||||||
self.do_tasks()
|
|
||||||
continue
|
|
||||||
if queued:
|
|
||||||
action, data = queued
|
|
||||||
if action == "trade":
|
|
||||||
self.do_trade(data)
|
|
||||||
elif action == "portreport":
|
|
||||||
self.do_report(*data)
|
|
||||||
elif action == "topten":
|
|
||||||
self.do_topten(*data)
|
|
||||||
except Exception:
|
|
||||||
traceback.print_exc()
|
|
||||||
continue
|
|
||||||
|
|
||||||
def do_topten(self, nick, replyto):
|
|
||||||
"""
|
|
||||||
Do lookup of highest valued portfolios
|
|
||||||
"""
|
|
||||||
self.log.warning("{} wants top 10 sent to {}".format(nick, replyto))
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
for num, row in enumerate(c.execute("""SELECT h1.nick as nick, h1.cents as cents
|
|
||||||
FROM stockplay_balance_history h1
|
|
||||||
INNER JOIN (SELECT nick, max(day) as MaxDate FROM stockplay_balance_history
|
|
||||||
WHERE nick != ? GROUP BY nick) h2
|
|
||||||
ON h1.nick = h2.nick AND h1.day = h2.MaxDate
|
|
||||||
ORDER BY cents DESC LIMIT 10""", (DUSTACCT, )).fetchall(), start=1):
|
|
||||||
total = Decimal(row["cents"]) / 100
|
|
||||||
rows.append(("#{}".format(num), row["nick"], "with total:", "~{}".format(format_decimal(total)), ))
|
|
||||||
|
|
||||||
for line in tabulate(rows, justify=[False, True, False, False]):
|
|
||||||
self.bot.act_PRIVMSG(replyto, line, priority=5)
|
|
||||||
|
|
||||||
def do_trade(self, trade):
|
|
||||||
"""
|
|
||||||
Perform a queued trade
|
|
||||||
|
|
||||||
:param trade: trade struct to perform
|
|
||||||
:type trade: Trade
|
|
||||||
"""
|
|
||||||
self.log.warning("{} wants to {} {} of {}".format(trade.nick,
|
|
||||||
"buy" if trade.buy else "sell",
|
|
||||||
trade.amount,
|
|
||||||
trade.symbol))
|
|
||||||
# Update quote price
|
|
||||||
try:
|
|
||||||
price = self.cache.get_price(trade.symbol, self.config["trade_cache_seconds"])
|
|
||||||
except Exception:
|
|
||||||
traceback.print_exc()
|
|
||||||
self.bot.act_PRIVMSG(trade.replyto, "{}: invalid symbol or api failure, trade aborted!"
|
|
||||||
.format(trade.nick))
|
|
||||||
return
|
|
||||||
if price is None:
|
|
||||||
self.bot.act_PRIVMSG(trade.replyto,
|
|
||||||
"{}: invalid symbol '{}'".format(trade.nick, trade.symbol))
|
|
||||||
return # invalid stock
|
|
||||||
|
|
||||||
symprice = price.price
|
|
||||||
|
|
||||||
# calculate various prices needed
|
|
||||||
# symprice -= Decimal("0.0001") # for testing dust collection
|
|
||||||
real_price = symprice * trade.amount * 100 # now in cents
|
|
||||||
|
|
||||||
self.log.info("real_price: {}".format(real_price))
|
|
||||||
|
|
||||||
if trade.buy:
|
|
||||||
trade_price = int(ceil(real_price))
|
|
||||||
dust = trade_price - real_price
|
|
||||||
|
|
||||||
else:
|
|
||||||
trade_price = int(floor(real_price))
|
|
||||||
dust = real_price - trade_price
|
|
||||||
|
|
||||||
self.log.info("trade_price: {}".format(trade_price))
|
|
||||||
self.log.info("dust: {}".format(dust))
|
|
||||||
|
|
||||||
# fetch existing user balances
|
|
||||||
nickbal = self.get_bal(trade.nick)
|
|
||||||
count = self.get_holding(trade.nick, trade.symbol)
|
|
||||||
|
|
||||||
# check if trade is legal
|
|
||||||
if trade.buy and nickbal < trade_price:
|
|
||||||
self.bot.act_PRIVMSG(trade.replyto, "{}: you can't afford {}."
|
|
||||||
.format(trade.nick, format_price(trade_price)))
|
|
||||||
return # can't afford trade
|
|
||||||
if not trade.buy and trade.amount > count:
|
|
||||||
self.bot.act_PRIVMSG(trade.replyto, "{}: you don't have that many.".format(trade.nick))
|
|
||||||
return # asked to sell more shares than they have
|
|
||||||
|
|
||||||
# perform trade calculations
|
|
||||||
if trade.buy:
|
|
||||||
nickbal -= trade_price
|
|
||||||
count += trade.amount
|
|
||||||
else:
|
|
||||||
nickbal += trade_price
|
|
||||||
count -= trade.amount
|
|
||||||
|
|
||||||
# commit the trade
|
|
||||||
self.set_bal(trade.nick, nickbal)
|
|
||||||
self.set_holding(trade.nick, trade.symbol, count)
|
|
||||||
|
|
||||||
# save dust
|
|
||||||
dustbal = self.get_bal(DUSTACCT)
|
|
||||||
self.set_bal(DUSTACCT, dustbal + int(dust * 100))
|
|
||||||
|
|
||||||
# notify user
|
|
||||||
message = "{} {} {} for {}. cash: {}".format("bought" if trade.buy else "sold",
|
|
||||||
trade.amount,
|
|
||||||
trade.symbol,
|
|
||||||
format_price(trade_price),
|
|
||||||
format_price(nickbal))
|
|
||||||
self.bot.act_PRIVMSG(trade.replyto, "{}: {}".format(trade.nick, message))
|
|
||||||
|
|
||||||
# announce message
|
|
||||||
if self.config.get("announce_trades"):
|
|
||||||
channel = self.config.get("announce_channel")
|
|
||||||
if channel:
|
|
||||||
self.bot.act_PRIVMSG(channel, "{}_ {}".format(trade.nick, message), priority=10)
|
|
||||||
|
|
||||||
self.log_trade(trade.nick, time(), "buy" if trade.buy else "sell",
|
|
||||||
trade.symbol, trade.amount, trade_price, str(symprice))
|
|
||||||
|
|
||||||
def do_report(self, lookup, sender, replyto, full):
|
|
||||||
"""
|
|
||||||
Generate a text report of the nick's portfolio ::
|
|
||||||
|
|
||||||
<player> .port profit full
|
|
||||||
<bloomberg_terminal> player: profit has cash: $491.02 stock value: ~$11,137.32 total: ~$11,628.34 (24h +1,504.37 (14.86%)⬆)
|
|
||||||
<bloomberg_terminal> player: 1 AAPL bought at average $170.41 +3.92 (2.30%)⬆ now $174.33
|
|
||||||
<bloomberg_terminal> player: 14 AMD bought at average $23.05 +1.16 (5.03%)⬆ now $24.21
|
|
||||||
<bloomberg_terminal> player: 25 DBX bought at average $25.42 -1.08 (-4.25%)⬇ now $24.34
|
|
||||||
<bloomberg_terminal> player: 10 DENN bought at average $17.94 -0.27 (-1.51%)⬇ now $17.67
|
|
||||||
<bloomberg_terminal> player: 18 EA bought at average $99.77 -1.27 (-1.28%)⬇ now $98.50
|
|
||||||
<bloomberg_terminal> player: 10 INTC bought at average $53.23 +0.00 (0.00%)⬆ now $53.23
|
|
||||||
<bloomberg_terminal> player: 160 KPTI bought at average $4.88 +0.00 (0.00%)⬆ now $4.88
|
|
||||||
"""
|
|
||||||
data = self.build_report(lookup)
|
|
||||||
dest = sender if full else replyto
|
|
||||||
|
|
||||||
self.bot.act_PRIVMSG(dest, "{}: {} cash: {} stock value: ~{} total: ~{} (24h {})"
|
|
||||||
.format(sender,
|
|
||||||
"you have" if lookup == sender else "{} has".format(lookup),
|
|
||||||
format_decimal(data["cash"]),
|
|
||||||
format_decimal(data["holding_value"]),
|
|
||||||
format_decimal(data["cash"] + data["holding_value"]),
|
|
||||||
format_gainloss(data["24hgain"], data["24hpct"])))
|
|
||||||
|
|
||||||
if not full:
|
|
||||||
return
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for symbol, count, symprice, avgbuy, buychange in data["holdings"]:
|
|
||||||
rows.append([count,
|
|
||||||
symbol,
|
|
||||||
"bought at average",
|
|
||||||
format_decimal(avgbuy),
|
|
||||||
*format_gainloss_inner(symprice - avgbuy, buychange),
|
|
||||||
"now",
|
|
||||||
format_decimal(symprice),
|
|
||||||
"({})".format(format_decimal(symprice * count))])
|
|
||||||
|
|
||||||
for line in tabulate(rows, justify=[False, True, True, False, False, False, True, False, False]):
|
|
||||||
self.bot.act_PRIVMSG(dest, "{}: {}".format(sender, line), priority=5)
|
|
||||||
|
|
||||||
def build_report(self, nick):
|
|
||||||
"""
|
|
||||||
Return a dict containing the player's cash, stock value, holdings listing, and 24 hour statistics.
|
|
||||||
"""
|
|
||||||
cash = Decimal(self.get_bal(nick)) / 100
|
|
||||||
|
|
||||||
# generate a list of (symbol, count, price, avgbuy, %change_on_avgbuy) tuples of the player's holdings
|
|
||||||
symbol_count = []
|
|
||||||
holding_value = Decimal(0)
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
for row in c.execute("SELECT * FROM stockplay_holdings WHERE count>0 AND nick=? ORDER BY count DESC",
|
|
||||||
(nick, )).fetchall():
|
|
||||||
# the API limits us to 5 requests per minute or 500 requests per day or about 1 request every 173s
|
|
||||||
# The background thread updates the oldest price every 5 minutes. Here, we allow even very stale quotes
|
|
||||||
# because it's simply impossible to request fresh data for every stock right now.
|
|
||||||
print("build_report: processing", row["symbol"])
|
|
||||||
price = self.cache.get_price(row["symbol"], -1)
|
|
||||||
symprice = price.price
|
|
||||||
holding_value += symprice * row["count"]
|
|
||||||
avgbuy = self.calc_user_avgbuy(nick, row["symbol"])
|
|
||||||
symbol_count.append((row["symbol"],
|
|
||||||
row["count"],
|
|
||||||
symprice,
|
|
||||||
avgbuy,
|
|
||||||
calc_gain(avgbuy, symprice)))
|
|
||||||
|
|
||||||
symbol_count.sort(key=lambda x: x[0]) # sort by symbol name
|
|
||||||
|
|
||||||
# calculate gain/loss percent
|
|
||||||
# TODO 1 week / 2 week / 1 month averages
|
|
||||||
day_start_bal = self.get_latest_hist_bal(nick)
|
|
||||||
gain_value = Decimal(0)
|
|
||||||
gain_pct = Decimal(0)
|
|
||||||
|
|
||||||
if day_start_bal:
|
|
||||||
newbal = cash + holding_value
|
|
||||||
startbal = Decimal(day_start_bal["cents"]) / 100
|
|
||||||
gain_value = newbal - startbal
|
|
||||||
gain_pct = calc_gain(Decimal(day_start_bal["cents"]) / 100, cash + holding_value)
|
|
||||||
|
|
||||||
return {"cash": cash,
|
|
||||||
"holdings": symbol_count,
|
|
||||||
"holding_value": holding_value,
|
|
||||||
"24hgain": gain_value,
|
|
||||||
"24hpct": gain_pct}
|
|
||||||
|
|
||||||
def do_tasks(self):
|
|
||||||
"""
|
|
||||||
Do interval tasks such as recording nightly balances
|
|
||||||
"""
|
|
||||||
now = time()
|
|
||||||
if now - 60 < self.task_time:
|
|
||||||
return
|
|
||||||
self.task_time = now
|
|
||||||
self.record_nightly_balances()
|
|
||||||
|
|
||||||
def checksym(self, s):
|
|
||||||
"""
|
|
||||||
Validate that a string looks like a stock symbol
|
|
||||||
"""
|
|
||||||
if len(s) > 12:
|
|
||||||
return
|
|
||||||
s = s.upper()
|
|
||||||
if not RE_SYMBOL.match(s):
|
|
||||||
return
|
|
||||||
return s
|
|
||||||
|
|
||||||
@info("buy <amount> <symbol>", "buy <amount> of stock <symbol>", cmds=["buy"])
|
|
||||||
@command("buy", require_args=True, allow_private=True)
|
|
||||||
@protected()
|
|
||||||
def cmd_buy(self, message, command):
|
|
||||||
"""
|
|
||||||
Command to buy stocks
|
|
||||||
"""
|
|
||||||
self.check_nick(message.prefix.nick)
|
|
||||||
amount = int(command.args[0])
|
|
||||||
symbol = self.checksym(command.args[1])
|
|
||||||
if not symbol or amount <= 0:
|
|
||||||
return
|
|
||||||
self.asyncq.put(("trade", Trade(message.prefix.nick,
|
|
||||||
True,
|
|
||||||
symbol,
|
|
||||||
amount,
|
|
||||||
message.args[0] if message.args[0].startswith("#") else message.prefix.nick)))
|
|
||||||
|
|
||||||
@info("sell <amount> <symbol>", "buy <amount> of stock <symbol>", cmds=["sell"])
|
|
||||||
@command("sell", require_args=True, allow_private=True)
|
|
||||||
@protected()
|
|
||||||
def cmd_sell(self, message, command):
|
|
||||||
"""
|
|
||||||
Command to sell stocks
|
|
||||||
"""
|
|
||||||
self.check_nick(message.prefix.nick)
|
|
||||||
amount = int(command.args[0])
|
|
||||||
symbol = self.checksym(command.args[1])
|
|
||||||
if not symbol or amount <= 0:
|
|
||||||
return
|
|
||||||
self.asyncq.put(("trade", Trade(message.prefix.nick,
|
|
||||||
False,
|
|
||||||
symbol,
|
|
||||||
amount,
|
|
||||||
message.args[0] if message.args[0].startswith("#") else message.prefix.nick)))
|
|
||||||
|
|
||||||
@info("port", "show portfolio holdings", cmds=["port", "portfolio"])
|
|
||||||
@command("port", "portfolio", allow_private=True)
|
|
||||||
@protected()
|
|
||||||
def cmd_port(self, message, command):
|
|
||||||
"""
|
|
||||||
Portfolio report command
|
|
||||||
"""
|
|
||||||
full = False
|
|
||||||
lookup = message.prefix.nick
|
|
||||||
if command.args:
|
|
||||||
if command.args[0] == "full":
|
|
||||||
full = True
|
|
||||||
else:
|
|
||||||
lookup = command.args[0]
|
|
||||||
if len(command.args) > 1 and command.args[1] == "full":
|
|
||||||
full = True
|
|
||||||
|
|
||||||
self.asyncq.put(("portreport", (lookup,
|
|
||||||
message.prefix.nick,
|
|
||||||
message.prefix.nick if full or not message.args[0].startswith("#")
|
|
||||||
else message.args[0],
|
|
||||||
full)))
|
|
||||||
|
|
||||||
@info("top", "show top portfolios", cmds=["top", "top10"])
|
|
||||||
@command("top", "top10", allow_private=True)
|
|
||||||
def cmd_top(self, message, command):
|
|
||||||
"""
|
|
||||||
Top 10 report command
|
|
||||||
"""
|
|
||||||
self.asyncq.put(("topten", (message.prefix.nick, message.prefix.nick)))
|
|
||||||
|
|
||||||
def check_nick(self, nick):
|
|
||||||
"""
|
|
||||||
Set up a user's account by setting the initial balance
|
|
||||||
"""
|
|
||||||
if not self.nick_exists(nick):
|
|
||||||
self.set_bal(nick, self.config["startbalance"] * 100) # initial balance for user
|
|
||||||
# TODO welcome message
|
|
||||||
# TODO maybe even some random free shares for funzies
|
|
||||||
|
|
||||||
def nick_exists(self, name):
|
|
||||||
"""
|
|
||||||
Check whether a nick has a record
|
|
||||||
"""
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
return c.execute("SELECT COUNT(*) as num FROM stockplay_balances WHERE nick=?",
|
|
||||||
(name, )).fetchone()["num"] and True
|
|
||||||
|
|
||||||
def set_bal(self, nick, amount):
|
|
||||||
"""
|
|
||||||
Set a player's balance
|
|
||||||
|
|
||||||
:param amount: new balance in cents
|
|
||||||
"""
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
c.execute("REPLACE INTO stockplay_balances VALUES (?, ?)",
|
|
||||||
(nick, amount, ))
|
|
||||||
|
|
||||||
def get_bal(self, nick):
|
|
||||||
"""
|
|
||||||
Get player's balance
|
|
||||||
:return: balance in cents
|
|
||||||
"""
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
return c.execute("SELECT * FROM stockplay_balances WHERE nick=?",
|
|
||||||
(nick, )).fetchone()["cents"]
|
|
||||||
|
|
||||||
def get_holding(self, nick, symbol):
|
|
||||||
"""
|
|
||||||
Return the number of stocks of a certain symbol a player has
|
|
||||||
"""
|
|
||||||
assert symbol == symbol.upper()
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
r = c.execute("SELECT * FROM stockplay_holdings WHERE nick=? AND symbol=?",
|
|
||||||
(nick, symbol, )).fetchone()
|
|
||||||
return r["count"] if r else 0
|
|
||||||
|
|
||||||
def set_holding(self, nick, symbol, count):
|
|
||||||
"""
|
|
||||||
Set the number of stocks of a certain symbol a player that
|
|
||||||
"""
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
c.execute("REPLACE INTO stockplay_holdings VALUES (?, ?, ?)",
|
|
||||||
(nick, symbol, count, ))
|
|
||||||
|
|
||||||
def log_trade(self, nick, time, type, symbol, count, price, symprice):
|
|
||||||
"""
|
|
||||||
Append a record of a trade to the database log
|
|
||||||
"""
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
c.execute("INSERT INTO stockplay_trades VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(nick, time, type, symbol, count, price, symprice, ))
|
|
||||||
|
|
||||||
def get_latest_hist_bal(self, nick):
|
|
||||||
"""
|
|
||||||
Return the most recent historical balance of a player. Aka their "opening" value.
|
|
||||||
"""
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
return c.execute("SELECT * FROM stockplay_balance_history WHERE nick=? ORDER BY DAY DESC LIMIT 1",
|
|
||||||
(nick, )).fetchone()
|
|
||||||
|
|
||||||
def record_nightly_balances(self):
|
|
||||||
"""
|
|
||||||
Create a record for each user's balance at the start of each day.
|
|
||||||
"""
|
|
||||||
now = (datetime.now() + timedelta(seconds=self.config.get("midnight_offset", 0))).strftime("%Y-%m-%d")
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
for row in c.execute("""SELECT * FROM stockplay_balances WHERE nick NOT IN
|
|
||||||
(SELECT nick FROM stockplay_balance_history WHERE day=?)""", (now, )).fetchall():
|
|
||||||
data = self.build_report(row["nick"])
|
|
||||||
total = int((data["cash"] + data["holding_value"]) * 100)
|
|
||||||
self.log.info("Recording {} daily balance for {}".format(now, row["nick"]))
|
|
||||||
c.execute("INSERT INTO stockplay_balance_history VALUES (?, ?, ?)", (row["nick"], now, total))
|
|
||||||
|
|
||||||
|
|
||||||
class PriceCache(object):
|
|
||||||
def __init__(self, mod):
|
|
||||||
self.sql = mod.sql
|
|
||||||
self.log = mod.log
|
|
||||||
self.mod = mod
|
|
||||||
self.providers = []
|
|
||||||
|
|
||||||
self.configure_providers(mod.config["providers"])
|
|
||||||
self.which_provider = dict()
|
|
||||||
self.unsupported = set()
|
|
||||||
|
|
||||||
def configure_providers(self, config):
|
|
||||||
for provider in config:
|
|
||||||
self.providers.append(PROVIDER_TYPES[provider["provider"]](provider, self.log))
|
|
||||||
|
|
||||||
def get_price(self, symbol, thresh):
|
|
||||||
symbol = symbol.upper()
|
|
||||||
# load from cache
|
|
||||||
price = self._load_priceinfo(symbol)
|
|
||||||
# if present and meets thresh
|
|
||||||
if price and (thresh == -1 or time() - price.time < thresh):
|
|
||||||
return price
|
|
||||||
|
|
||||||
if symbol in self.unsupported:
|
|
||||||
return
|
|
||||||
|
|
||||||
return self.api_fetch(symbol)
|
|
||||||
|
|
||||||
def api_fetch(self, symbol):
|
|
||||||
fetched = None
|
|
||||||
|
|
||||||
if symbol in self.which_provider:
|
|
||||||
fetched = self.which_provider[symbol].get_price(symbol)
|
|
||||||
|
|
||||||
if not fetched:
|
|
||||||
for provider in self.providers:
|
|
||||||
try:
|
|
||||||
fetched = provider.get_price(symbol)
|
|
||||||
self.which_provider[symbol] = provider
|
|
||||||
break
|
|
||||||
except NotSupported as nse:
|
|
||||||
self.unsupported.update([symbol])
|
|
||||||
self.log.info("provider {}: {}".format(provider.__class__.__name__, nse))
|
|
||||||
|
|
||||||
if not fetched:
|
|
||||||
self.log.critical("unsupported symbol: %s", symbol)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._store_priceinfo(fetched)
|
|
||||||
|
|
||||||
return fetched
|
|
||||||
|
|
||||||
def _store_priceinfo(self, price):
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
c.execute("REPLACE INTO stockplay_prices (symbol, attempt_time, time, data) VALUES (?, ?, ?, ?)",
|
|
||||||
(price.symbol, price.time, time(), price.to_json()))
|
|
||||||
|
|
||||||
def _load_priceinfo(self, symbol):
|
|
||||||
with closing(self.sql.getCursor()) as c:
|
|
||||||
row = c.execute("SELECT * FROM stockplay_prices WHERE symbol=?",
|
|
||||||
(symbol, )).fetchone()
|
|
||||||
if not row:
|
|
||||||
return
|
|
||||||
return Price.from_json(row["data"])
|
|
||||||
|
|
||||||
|
|
||||||
class Price(object):
|
|
||||||
def __init__(self, symbol, price, time_):
|
|
||||||
self.symbol = symbol.upper()
|
|
||||||
self.price = round(Decimal(price), 4)
|
|
||||||
self.time = time_
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return json.dumps({
|
|
||||||
"symbol": self.symbol,
|
|
||||||
"price": str(self.price),
|
|
||||||
"time": self.time,
|
|
||||||
})
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_json(data):
|
|
||||||
data = json.loads(data)
|
|
||||||
return Price(data["symbol"].upper(), data["price"], data.get("time", 0))
|
|
||||||
|
|
||||||
|
|
||||||
class PriceProvider(object):
|
|
||||||
def __init__(self, config, logger):
|
|
||||||
"""
|
|
||||||
config::
|
|
||||||
|
|
||||||
{
|
|
||||||
"provider": "name",
|
|
||||||
"apikey": "xxxxxxxxxxxxxx",
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.config = config
|
|
||||||
self.log = logger
|
|
||||||
|
|
||||||
def get_price(self, symbol):
|
|
||||||
"""
|
|
||||||
:return: tuple of:
|
|
||||||
|
|
||||||
* price (as a Decimal)
|
|
||||||
* next_after (the time() after which the next background call should happen)
|
|
||||||
|
|
||||||
or raise:
|
|
||||||
|
|
||||||
NotSupported - if the symbol isnt supported
|
|
||||||
"""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
|
|
||||||
class IEXCloudProvider(PriceProvider):
|
|
||||||
def get_price(self, symbol):
|
|
||||||
"""
|
|
||||||
Request a stock quote from the API. The API provides the format::
|
|
||||||
|
|
||||||
{"symbol": "AAPL",
|
|
||||||
"companyName": "Apple, Inc.",
|
|
||||||
"calculationPrice": "close",
|
|
||||||
"open": 184.7,
|
|
||||||
"openTime": 1552656600847,
|
|
||||||
"close": 186.12,
|
|
||||||
"closeTime": 1552680000497,
|
|
||||||
"high": 187.33,
|
|
||||||
"low": 183.74,
|
|
||||||
"latestPrice": 186.12,
|
|
||||||
"latestSource": "Close",
|
|
||||||
"latestTime": "March 15, 2019",
|
|
||||||
"latestUpdate": 1552680000497,
|
|
||||||
"latestVolume": 39141464,
|
|
||||||
"iexRealtimePrice": 186.195,
|
|
||||||
"iexRealtimeSize": 100,
|
|
||||||
"iexLastUpdated": 1552679999536,
|
|
||||||
"delayedPrice": 186.124,
|
|
||||||
"delayedPriceTime": 1552680900008,
|
|
||||||
"extendedPrice": 185.92,
|
|
||||||
"extendedChange": -0.2,
|
|
||||||
"extendedChangePercent": -0.00107,
|
|
||||||
"extendedPriceTime": 1552693471549,
|
|
||||||
"previousClose": 183.73,
|
|
||||||
"change": 2.39,
|
|
||||||
"changePercent": 0.01301,
|
|
||||||
"iexMarketPercent": 0.021849182749015213,
|
|
||||||
"iexVolume": 855209,
|
|
||||||
"avgTotalVolume": 25834564,
|
|
||||||
"iexBidPrice": 0,
|
|
||||||
"iexBidSize": 0,
|
|
||||||
"iexAskPrice": 0,
|
|
||||||
"iexAskSize": 0,
|
|
||||||
"marketCap": 877607913600,
|
|
||||||
"peRatio": 15.17,
|
|
||||||
"week52High": 233.47,
|
|
||||||
"week52Low": 142,
|
|
||||||
"ytdChange": 0.176447}
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.log.info("{}: fetching api quote for symbol: {}".format(self.__class__.__name__, symbol))
|
|
||||||
|
|
||||||
response = get("https://cloud.iexapis.com/beta/stock/{}/quote".format(symbol.lower()),
|
|
||||||
params={"token": self.config["apikey"]},
|
|
||||||
timeout=10)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
if response.status_code == 404:
|
|
||||||
raise NotSupported(symbol)
|
|
||||||
else:
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
return Price(symbol, Decimal(data["latestPrice"]), int(time()))
|
|
||||||
|
|
||||||
|
|
||||||
class AlphaVantProvider(PriceProvider):
|
|
||||||
def get_price(self, symbol):
|
|
||||||
"""
|
|
||||||
Request a stock quote from the API. The API provides the format::
|
|
||||||
|
|
||||||
{'Global Quote': {
|
|
||||||
{'01. symbol': 'MSFT',
|
|
||||||
'02. open': '104.3900',
|
|
||||||
'03. high': '105.7800',
|
|
||||||
'04. low': '104.2603',
|
|
||||||
'05. price': '105.6700',
|
|
||||||
'06. volume': '21461093',
|
|
||||||
'07. latest trading day':'2019-02-08',
|
|
||||||
'08. previous close':'105.2700',
|
|
||||||
'09. change': '0.4000',
|
|
||||||
'10. change percent': '0.3800%'}}
|
|
||||||
"""
|
|
||||||
self.log.info("{}: fetching api quote for symbol: {}".format(self.__class__.__name__, symbol))
|
|
||||||
|
|
||||||
data = get("https://www.alphavantage.co/query",
|
|
||||||
params={"function": "GLOBAL_QUOTE",
|
|
||||||
"symbol": symbol,
|
|
||||||
"apikey": self.config["apikey"]},
|
|
||||||
timeout=10).json()
|
|
||||||
|
|
||||||
if "Global Quote" not in data:
|
|
||||||
raise NotSupported(symbol)
|
|
||||||
|
|
||||||
return Price(symbol, Decimal(data["Global Quote"]["05. price"]), int(time()))
|
|
||||||
|
|
||||||
|
|
||||||
PROVIDER_TYPES = {
|
|
||||||
"iexcloud": IEXCloudProvider,
|
|
||||||
"alphavantage": AlphaVantProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class NotSupported(Exception):
|
|
||||||
pass
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""
|
||||||
|
.. module::XDCC
|
||||||
|
:synopsis: Provide XDCC filebot functionality
|
||||||
|
.. moduleauthor::Dave Pedu <dave@davepedu.com>
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pyircbot.modulebase import ModuleBase, command, hook, regex
|
||||||
|
from pyircbot.modules.ModInfo import info
|
||||||
|
from pyircbot.modules.DCC import int2ip
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
|
||||||
|
class XDCC(ModuleBase):
|
||||||
|
def __init__(self, bot, name):
|
||||||
|
super().__init__(bot, name)
|
||||||
|
self.dcc = self.bot.getBestModuleForService("dcc")
|
||||||
|
|
||||||
|
@regex(r'cdc list', types=['PRIVMSG'])
|
||||||
|
def xdcc_list(self, msg, match):
|
||||||
|
files = sorted(os.listdir(self.config.get("share")))
|
||||||
|
for i, f in enumerate(files):
|
||||||
|
self.bot.act_PRIVMSG(msg.args[0], "{}: {}: {}".format(msg.prefix.nick, i, f))
|
|
@ -10,7 +10,6 @@ import logging
|
||||||
import sys
|
import sys
|
||||||
from pyircbot.rpc import BotRPC
|
from pyircbot.rpc import BotRPC
|
||||||
from pyircbot.irccore import IRCCore
|
from pyircbot.irccore import IRCCore
|
||||||
from pyircbot.common import report
|
|
||||||
from socket import AF_INET, AF_INET6
|
from socket import AF_INET, AF_INET6
|
||||||
import os.path
|
import os.path
|
||||||
import asyncio
|
import asyncio
|
||||||
|
@ -45,7 +44,6 @@ class ModuleLoader(object):
|
||||||
self.log.error("Module %s failed to load: " % name)
|
self.log.error("Module %s failed to load: " % name)
|
||||||
self.log.error("Module load failure reason: " + str(e))
|
self.log.error("Module load failure reason: " + str(e))
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
report(e)
|
|
||||||
return (False, str(e))
|
return (False, str(e))
|
||||||
else:
|
else:
|
||||||
self.log.warning("Module %s already imported" % name)
|
self.log.warning("Module %s already imported" % name)
|
||||||
|
|
|
@ -1,37 +1,47 @@
|
||||||
apipkg==1.4
|
apipkg==1.4
|
||||||
attrs==17.4.0
|
appdirs==1.4.3
|
||||||
backports.functools-lru-cache==1.5
|
certifi==2017.4.17
|
||||||
certifi==2018.4.16
|
|
||||||
chardet==3.0.4
|
chardet==3.0.4
|
||||||
cheroot==6.2.4
|
cheroot==5.9.1
|
||||||
CherryPy==14.2.0
|
CherryPy==12.0.1
|
||||||
coverage==4.5.1
|
coverage==4.4.2
|
||||||
|
decorator==4.0.11
|
||||||
execnet==1.5.0
|
execnet==1.5.0
|
||||||
idna==2.6
|
idna==2.5
|
||||||
lxml==4.3.1
|
ipdb==0.10.3
|
||||||
more-itertools==4.1.0
|
ipython==6.0.0
|
||||||
-e git+http://git.davepedu.com/dave/pymsgbus.git@373a9c5f153078fce57bde43f493785859f51de4#egg=msgbus
|
ipython-genutils==0.2.0
|
||||||
pluggy==0.6.0
|
jaraco.classes==1.4.3
|
||||||
|
jedi==0.10.2
|
||||||
|
lxml==4.1.1
|
||||||
|
mock==2.0.0
|
||||||
|
-e git+http://gitlab.davepedu.com/dave/pymsgbus.git#egg=msgbus
|
||||||
|
packaging==16.8
|
||||||
|
pbr==3.1.1
|
||||||
|
pexpect==4.2.1
|
||||||
|
pickleshare==0.7.4
|
||||||
portend==2.2
|
portend==2.2
|
||||||
praw==5.4.0
|
praw==5.0.1
|
||||||
prawcore==0.14.0
|
prawcore==0.11.0
|
||||||
py==1.5.3
|
prompt-toolkit==1.0.14
|
||||||
PyJWT==1.6.1
|
ptyprocess==0.5.1
|
||||||
PyMySQL==0.8.0
|
py==1.5.2
|
||||||
PySocks==1.6.8
|
Pygments==2.2.0
|
||||||
pytest==3.5.0
|
PyJWT==1.5.3
|
||||||
|
pyparsing==2.2.0
|
||||||
|
PySocks==1.6.7
|
||||||
|
pytest==3.2.5
|
||||||
pytest-cov==2.5.1
|
pytest-cov==2.5.1
|
||||||
pytest-cover==3.0.0
|
|
||||||
pytest-coverage==0.0
|
|
||||||
pytest-forked==0.2
|
pytest-forked==0.2
|
||||||
pytest-xdist==1.22.2
|
pytest-xdist==1.20.1
|
||||||
-e git+https://github.com/jgarzik/python-bitcoinrpc.git@76ced424dc16f997365265487487056e653238c6#egg=python_bitcoinrpc
|
pytz==2017.3
|
||||||
pytz==2018.4
|
pyzmq==16.0.2
|
||||||
pyzmq==17.0.0
|
requests==2.18.1
|
||||||
-e git+https://github.com/bitprophet/releases.git@1.6.1#egg=releases
|
simplegeneric==0.8.1
|
||||||
requests==2.18.4
|
|
||||||
six==1.11.0
|
six==1.11.0
|
||||||
tempora==1.11
|
tempora==1.9
|
||||||
twilio==6.12.1
|
traitlets==4.3.2
|
||||||
|
twilio==6.9.0
|
||||||
update-checker==0.16
|
update-checker==0.16
|
||||||
urllib3==1.22
|
urllib3==1.21.1
|
||||||
|
wcwidth==0.1.7
|
||||||
|
|
|
@ -1,24 +1,35 @@
|
||||||
backports.functools-lru-cache==1.5
|
alabaster==0.7.10
|
||||||
certifi==2018.4.16
|
Babel==2.5.3
|
||||||
|
certifi==2017.11.5
|
||||||
chardet==3.0.4
|
chardet==3.0.4
|
||||||
cheroot==6.2.4
|
cheroot==6.0.0
|
||||||
CherryPy==14.2.0
|
CherryPy==13.1.0
|
||||||
|
docutils==0.14
|
||||||
idna==2.6
|
idna==2.6
|
||||||
lxml==4.3.1
|
imagesize==0.7.1
|
||||||
more-itertools==4.1.0
|
Jinja2==2.10
|
||||||
|
lxml==4.1.1
|
||||||
|
MarkupSafe==1.0
|
||||||
|
more-itertools==4.0.1
|
||||||
|
pyzmq==16.0.3
|
||||||
-e git+http://git.davepedu.com/dave/pymsgbus.git@373a9c5f153078fce57bde43f493785859f51de4#egg=msgbus
|
-e git+http://git.davepedu.com/dave/pymsgbus.git@373a9c5f153078fce57bde43f493785859f51de4#egg=msgbus
|
||||||
portend==2.2
|
portend==2.2
|
||||||
praw==5.4.0
|
praw==5.3.0
|
||||||
prawcore==0.14.0
|
prawcore==0.13.0
|
||||||
PyJWT==1.6.1
|
Pygments==2.2.0
|
||||||
|
PyJWT==1.5.3
|
||||||
PyMySQL==0.8.0
|
PyMySQL==0.8.0
|
||||||
PySocks==1.6.8
|
PySocks==1.6.8
|
||||||
-e git+https://github.com/jgarzik/python-bitcoinrpc.git@76ced424dc16f997365265487487056e653238c6#egg=python_bitcoinrpc
|
-e git+https://github.com/jgarzik/python-bitcoinrpc.git@76ced424dc16f997365265487487056e653238c6#egg=python_bitcoinrpc
|
||||||
pytz==2018.4
|
pytz==2017.3
|
||||||
pyzmq==17.0.0
|
releases==1.4.0
|
||||||
requests==2.18.4
|
requests==2.18.4
|
||||||
|
semantic-version==2.6.0
|
||||||
six==1.11.0
|
six==1.11.0
|
||||||
tempora==1.11
|
snowballstemmer==1.2.1
|
||||||
twilio==6.12.1
|
Sphinx==1.6.6
|
||||||
|
sphinxcontrib-websupport==1.0.1
|
||||||
|
tempora==1.10
|
||||||
|
twilio==6.9.0
|
||||||
update-checker==0.16
|
update-checker==0.16
|
||||||
urllib3==1.22
|
urllib3==1.22
|
||||||
|
|
|
@ -6,4 +6,4 @@ shift || true
|
||||||
export PYTHONUNBUFFERED=1
|
export PYTHONUNBUFFERED=1
|
||||||
export PYTHONPATH=.
|
export PYTHONPATH=.
|
||||||
|
|
||||||
pyircbot -c $CONFPATH --debug $@
|
./bin/pyircbot -c $CONFPATH --debug $@
|
||||||
|
|
|
@ -3,7 +3,4 @@
|
||||||
export PYTHONUNBUFFERED=1
|
export PYTHONUNBUFFERED=1
|
||||||
export PYTHONPATH=.
|
export PYTHONPATH=.
|
||||||
|
|
||||||
find pyircbot tests -name '*.pyc' -delete
|
|
||||||
find pyircbot tests -name __pycache__ -exec rm -rf {} \;
|
|
||||||
|
|
||||||
py.test --cov=pyircbot --cov-report html -n 4 tests/ $@
|
py.test --cov=pyircbot --cov-report html -n 4 tests/ $@
|
||||||
|
|
11
setup.py
11
setup.py
|
@ -1,19 +1,14 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
__version__ = "4.1.0"
|
__version__ = "4.0.0-r03"
|
||||||
|
|
||||||
setup(name='pyircbot',
|
setup(name='pyircbot',
|
||||||
version=__version__,
|
version='4.0.0-r03',
|
||||||
description='A modular python irc bot',
|
description='A modular python irc bot',
|
||||||
url='http://gitlab.xmopx.net/dave/pyircbot3/tree/master',
|
url='http://gitlab.xmopx.net/dave/pyircbot3/tree/master',
|
||||||
author='dpedu',
|
author='dpedu',
|
||||||
author_email='dave@davepedu.com',
|
author_email='dave@davepedu.com',
|
||||||
packages=['pyircbot', 'pyircbot.modules'],
|
packages=['pyircbot', 'pyircbot.modules'],
|
||||||
entry_points={
|
scripts=['bin/pyircbot'],
|
||||||
"console_scripts": [
|
|
||||||
"pyircbot = pyircbot.cli:main",
|
|
||||||
"pubsubbot = pyircbot.clipub:main"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
zip_safe=False)
|
zip_safe=False)
|
||||||
|
|
10
tests/lib.py
10
tests/lib.py
|
@ -5,7 +5,7 @@ from threading import Thread
|
||||||
from random import randint
|
from random import randint
|
||||||
from pyircbot import PyIRCBot
|
from pyircbot import PyIRCBot
|
||||||
from pyircbot.pyircbot import PrimitiveBot
|
from pyircbot.pyircbot import PrimitiveBot
|
||||||
from pyircbot.irccore import IRCEvent, UserPrefix, IRCCore
|
from pyircbot.irccore import IRCEvent, UserPrefix
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from tests.miniircd import Server as MiniIrcServer
|
from tests.miniircd import Server as MiniIrcServer
|
||||||
|
|
||||||
|
@ -27,10 +27,10 @@ class FakeBaseBot(PrimitiveBot):
|
||||||
"""
|
"""
|
||||||
Feed a message into the bot.
|
Feed a message into the bot.
|
||||||
"""
|
"""
|
||||||
msg = IRCCore.packetAsObject(cmd,
|
msg = IRCEvent(cmd,
|
||||||
args,
|
args,
|
||||||
f"{sender[0]}!{sender[1]}@{sender[2]}", # hack
|
UserPrefix(*sender),
|
||||||
trailing)
|
trailing)
|
||||||
|
|
||||||
for module_name, module in self.moduleInstances.items():# TODO dedupe this block across the various base classes
|
for module_name, module in self.moduleInstances.items():# TODO dedupe this block across the various base classes
|
||||||
for hook in module.irchooks:
|
for hook in module.irchooks:
|
||||||
|
|
|
@ -70,9 +70,8 @@ def test_getbal_authed(cryptobot):
|
||||||
|
|
||||||
def test_setup(cryptobot, mynick="chatter"):
|
def test_setup(cryptobot, mynick="chatter"):
|
||||||
pm(cryptobot, ".setpass foobar", nick=mynick)
|
pm(cryptobot, ".setpass foobar", nick=mynick)
|
||||||
cryptobot.act_PRIVMSG.assert_called_once_with(mynick, '.setpass: You\'ve been logged in and your password has been set to "foobar".')
|
cryptobot.act_PRIVMSG.assert_called_once_with(mynick, '.setpass: Your password has been set to "foobar".')
|
||||||
cryptobot.act_PRIVMSG.reset_mock()
|
cryptobot.act_PRIVMSG.reset_mock()
|
||||||
# TODO shouldn't need .login here, the setpass does it
|
|
||||||
pm(cryptobot, ".login foobar", nick=mynick)
|
pm(cryptobot, ".login foobar", nick=mynick)
|
||||||
cryptobot.act_PRIVMSG.assert_called_once_with(mynick, '.login: You have been logged in from: cia.gov')
|
cryptobot.act_PRIVMSG.assert_called_once_with(mynick, '.login: You have been logged in from: cia.gov')
|
||||||
cryptobot.act_PRIVMSG.reset_mock()
|
cryptobot.act_PRIVMSG.reset_mock()
|
||||||
|
|
|
@ -36,7 +36,7 @@ def test_register(nickbot):
|
||||||
nickbot.act_PRIVMSG.assert_called_once_with('chatter', '.setpass: usage: ".setpass newpass" or ".setpass oldpass newpass"')
|
nickbot.act_PRIVMSG.assert_called_once_with('chatter', '.setpass: usage: ".setpass newpass" or ".setpass oldpass newpass"')
|
||||||
nickbot.act_PRIVMSG.reset_mock()
|
nickbot.act_PRIVMSG.reset_mock()
|
||||||
pm(nickbot, ".setpass foobar")
|
pm(nickbot, ".setpass foobar")
|
||||||
nickbot.act_PRIVMSG.assert_called_once_with('chatter', '.setpass: You\'ve been logged in and your password has been set to "foobar".')
|
nickbot.act_PRIVMSG.assert_called_once_with('chatter', '.setpass: Your password has been set to "foobar".')
|
||||||
nickbot.act_PRIVMSG.reset_mock()
|
nickbot.act_PRIVMSG.reset_mock()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,72 +0,0 @@
|
||||||
import pytest
|
|
||||||
from contextlib import closing
|
|
||||||
from tests.lib import * # NOQA - fixtures
|
|
||||||
from time import sleep, time
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def stockbot(fakebot):
|
|
||||||
"""
|
|
||||||
Provide a bot loaded with the Calc module. Clear the database.
|
|
||||||
"""
|
|
||||||
fakebot.botconfig["module_configs"]["StockPlay"] = {
|
|
||||||
"startbalance": 10000,
|
|
||||||
"tradedelay": 0,
|
|
||||||
"tcachesecs": 120,
|
|
||||||
"bginterval": 45,
|
|
||||||
"announce_trades": True,
|
|
||||||
"announce_channel": "#trades",
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"provider": "iexcloud",
|
|
||||||
"apikey": "xxxxxxxxxxxxxxxxxxxxxx",
|
|
||||||
"background_interval": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"provider": "alphavantage",
|
|
||||||
"apikey": "xxxxxxxxxxxxxxxxxxxxxx",
|
|
||||||
"background_interval": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
fakebot.loadmodule("SQLite")
|
|
||||||
# with closing(fakebot.moduleInstances["SQLite"].opendb("remind.db")) as db:
|
|
||||||
# db.query("DROP TABLE IF EXISTS `reminders`;")
|
|
||||||
# fakebot.loadmodule("Remind")
|
|
||||||
|
|
||||||
# os.system("cp /Users/dave/code/pyircbot-work/examples/data2/data/SQLite/stockplay.db {}".format(fakebot.moduleInstances["SQLite"].getFilePath()))
|
|
||||||
# os.system("ln -s /Users/dave/code/pyircbot-work/examples/data2/data/SQLite/stockplay.db {}".format(fakebot.moduleInstances["SQLite"].getFilePath()))
|
|
||||||
|
|
||||||
fakebot.loadmodule("StockPlay")
|
|
||||||
return fakebot
|
|
||||||
|
|
||||||
|
|
||||||
# @pytest.mark.slow
|
|
||||||
# def test_stockplay(stockbot):
|
|
||||||
# sp = stockbot.moduleInstances["StockPlay"]
|
|
||||||
# # import pdb
|
|
||||||
# # pdb.set_trace()
|
|
||||||
# # print(sp.cache)
|
|
||||||
# # print(sp.cache.get_price("AmD", 60))
|
|
||||||
# # print(sp.cache.get_price("AmD", 60))
|
|
||||||
# # print(sp.cache.get_price("AmD", 60))
|
|
||||||
# # print(sp.cache.get_price("nut", 60))
|
|
||||||
|
|
||||||
# symbols = set()
|
|
||||||
|
|
||||||
# with closing(sp.sql.getCursor()) as c:
|
|
||||||
# for row in c.execute("SELECT * FROM stockplay_holdings").fetchall():
|
|
||||||
# symbols.update([row["symbol"].lower()])
|
|
||||||
|
|
||||||
# print(symbols)
|
|
||||||
|
|
||||||
# # # symbols = "a bah chk crm cron f fb mdla nio too tsla".split()
|
|
||||||
# for symbol in symbols:
|
|
||||||
# p = sp.cache.get_price(symbol, 0)
|
|
||||||
# if not p:
|
|
||||||
# print("not supported:", symbol)
|
|
||||||
# continue
|
|
||||||
# print(symbol, "age: ", time() - p.time)
|
|
||||||
|
|
||||||
# # print(sp.cache.get_price("gigl", 60))
|
|
|
@ -3,6 +3,7 @@ import pytest
|
||||||
from pyircbot import jsonrpc
|
from pyircbot import jsonrpc
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from random import randint
|
from random import randint
|
||||||
|
from socket import SHUT_RDWR
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
|
|
||||||
|
@ -36,7 +37,7 @@ def j1testserver():
|
||||||
Thread(target=server.serve, daemon=True).start()
|
Thread(target=server.serve, daemon=True).start()
|
||||||
sleep(0.1) # Give the serve() time to set up the serversocket
|
sleep(0.1) # Give the serve() time to set up the serversocket
|
||||||
yield (server, port)
|
yield (server, port)
|
||||||
server._Server__transport.close()
|
server._Server__transport.s.shutdown(SHUT_RDWR)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -47,9 +48,9 @@ def j2testserver():
|
||||||
server.register_function(sample)
|
server.register_function(sample)
|
||||||
server.register_instance(_sample(), name="obj")
|
server.register_instance(_sample(), name="obj")
|
||||||
Thread(target=server.serve, daemon=True).start()
|
Thread(target=server.serve, daemon=True).start()
|
||||||
sleep(0.2) # Give the serve() time to set up the serversocket
|
sleep(0.1) # Give the serve() time to set up the serversocket
|
||||||
yield (server, port)
|
yield (server, port)
|
||||||
server._Server__transport.close()
|
server._Server__transport.s.shutdown(SHUT_RDWR)
|
||||||
|
|
||||||
|
|
||||||
# Basic functionality
|
# Basic functionality
|
||||||
|
|
Loading…
Reference in New Issue