Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds helper bot #285

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ fix:
autopep8 --in-place -r -a examples

coverage:
python3 -m coverage run --source=tda -m nose
python3 -m coverage run --source=tda,discord_help_bot -m nose
python3 -m coverage html

dist: clean
Expand Down
1 change: 1 addition & 0 deletions discord_help_bot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sqlite
26 changes: 26 additions & 0 deletions discord_help_bot/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM arm64v8/alpine:3.15.0

# Install system-level dependcies
ENV PYTHONUNBUFFERED=1
RUN apk add --update --no-cache python3
RUN ln -sf python3 /usr/bin/python

RUN apk add --update --no-cache build-base
RUN apk add --update --no-cache python3-dev
RUN apk add --update --no-cache sqlite

RUN python3 -m ensurepip
RUN pip3 install --no-cache --upgrade pip setuptools

# Copy application
RUN mkdir /usr/discord_help_bot

COPY config.yml /usr/discord_help_bot
COPY *.py /usr/discord_help_bot
COPY docker-run.sh /usr/discord_help_bot
COPY requirements.txt /usr/discord_help_bot

# Install python requirements
RUN cd /usr/discord_help_bot && pip install -r requirements.txt

ENTRYPOINT ["python", "/usr/discord_help_bot/bot.py"]
49 changes: 49 additions & 0 deletions discord_help_bot/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
``tda-api`` Helper Bot
======================

A bot that listens on tda-api server traffic and chimes in whenever it feels a
user is asking a frequently-asked question.


How does it work?
-----------------

It listens on incoming messages and matches them against a configured list of
trigger prompts. When one of those prompts triggers, it responds to the message
with a (hopefully) helpful link to the appropriate FAQ page. It avoids showing
the same message to users over and over again by maintaining a database of the
messages each user has triggered.


How do I deploy it?
-------------------

This bot is packaged as a docker container. I use an M1 MacBook, the
``Dockerfile`` builds an image using the ``arm64v8`` version of Alpine Linux.
This also *happens* to be suitable for deployment to a Raspberry Pi 4B.

To deploy, first build the docker container:

.. code-block:: bash
cd /tda-api/repo/path/discord_help_bot
docker build .

Create the volume on which you'll store the state DB:

.. code-block:: bash
docker volume create discord_bot_state

# Find the ID of the created image here:
docker images

Initialize the state:

.. code-block:: bash
docker run -t -v discord_bot_state:/state <IMAGE_ID> init --sqlite_db_file
/sqlite/state.sqlite_db

Start the bot:

.. code-block:: bash
docker run -t -v discord_bot_state:/state <IMAGE_ID> run --token
<DISCORD_TOKEN>
119 changes: 119 additions & 0 deletions discord_help_bot/bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import argparse
import collections
import json
import logging
import os
import sys

from sqlalchemy.orm import sessionmaker

import discord
import yaml

from models import Base, User, TriggeredPrompt, get_engine


################################################################################
# Bot implementation


class HelperBot(discord.Client):

def __init__(self, config, db_engine):
super().__init__()

self.config = config
self.engine = db_engine
self.session = sessionmaker(bind=self.engine)()


async def on_ready(self):
print(f'Logged in as {self.user} (ID: {self.user.id})')

async def on_message(self, message):
if message.author.id == self.user.id:
return

for prompt_name, prompt in self.config['prompts'].items():
for trigger in prompt['triggers']:
if (trigger in message.content.lower()
and self.should_trigger_for_prompt(
prompt_name, message.author)):
await message.reply(prompt['response'])
self.record_prompt_seen(prompt_name, trigger, message)


def should_trigger_for_prompt(self, prompt_name, discord_user):
'Indicates whether the user should see the given prompt'
user_id = discord_user.id
prompts_seen = User.get_triggered_prompt_for_user(
self.session, prompt_name, user_id)
return len(prompts_seen) == 0


def record_prompt_seen(
self, prompt_name, triggered_string, discord_message):
'Record that the author of this message was sent this prompt.'
# Get/create the user
user = User.get_user_with_discord_id(
self.session, discord_message.author.id)
if not user:
user = User.new_user(
discord_message.author.name, discord_message.author.id)
self.session.add(user)
self.session.flush()

# Record the triggered prompt
triggered_prompt = TriggeredPrompt(
user_id=user.id,
prompt_name=prompt_name,
trigger_message=discord_message.content,
trigger_string=triggered_string)
self.session.add(TriggeredPrompt.record_prompt_for_user(
user, prompt_name, discord_message.content, triggered_string))

self.session.commit()

return triggered_prompt


################################################################################
# Main functions


def run_bot_main(args):
with open(args.config, 'r') as f:
config = yaml.safe_load(f)
engine = get_engine(args.sqlite_db_file)

client = HelperBot(config, engine)
client.run(args.token)


def main(argv):
parser = argparse.ArgumentParser('FAQ-based helper bot for tda-api')
subparsers = parser.add_subparsers(metavar='', dest='command')

run_parser = subparsers.add_parser('run', help=
'Run the Discord bot. Assumes all state is initialized.')
run_parser.add_argument('--token', required=True, help='Discord API token')
run_parser.add_argument('--config', required=True, help='Path to config YAML')
run_parser.add_argument('--sqlite_db_file', required=True, help=
'Location of sqlite3 database file')

init_parser = subparsers.add_parser('init', help=
'Initialize all state, including database state.')
init_parser.add_argument('--sqlite_db_file', required=True, help=
'Location of sqlite3 database file')

args = parser.parse_args(argv)

if args.command == 'run':
run_bot_main(args)
elif args.command == 'init':
Base.metadata.create_all(get_engine(args.sqlite_db_file))
else: # pragma: no cover
assert False

if __name__ == '__main__': # pragma: no cover
main(sys.argv[1:])
11 changes: 11 additions & 0 deletions discord_help_bot/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
prompts:
third-party-application:
triggers: [
'third-party application',
'unauthorized access to your account',
]
response: >
It looks like you might be asking for help about a common authentication issue. Please see our FAQ here:


https://tda-api.readthedocs.io/en/latest/auth.html#a-third-party-application-may-be-attempting-to-make-unauthorized-access-to-your-account
100 changes: 100 additions & 0 deletions discord_help_bot/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import os

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.sql import func


Base = declarative_base()


class User(Base):
'''
A discord user.
'''
__tablename__ = 'User'

id = Column(Integer, primary_key=True)

# Time when this user was first observed
addition_time = Column(DateTime(timezone=True), server_default=func.now())
# User's username on discord on the message which prompted them to be added
# to the DB. They might go by a different username now.
discord_username = Column(String)
# Discord ID of the user. Immutable on Discord's side.
discord_id = Column(Integer, index=True, unique=True)

# Prompts which were triggered for this user.
triggered_prompts = relationship('TriggeredPrompt')


def __repr__(self):
return (f'User(id={self.id}, addition_time={self.addition_time}, '+
f'discord_username={self.discord_username}, '+
f'discord_id={self.discord_id})')


@classmethod
def new_user(cls, discord_user_name, discord_user_id):
'Create a new user'
return User(
discord_username=discord_user_name,
discord_id=discord_user_id)


@classmethod
def get_user_with_discord_id(cls, session, discord_user_id):
'Get user by ID'
return (session
.query(User)
.filter_by(discord_id=discord_user_id)
.scalar())


@classmethod
def get_triggered_prompt_for_user(cls, session, prompt_name, discord_user_id):
'''Get a triggered prompt for a given user, or returns None if the user
never triggered that prompt.'''
return (session
.query(TriggeredPrompt)
.filter_by(prompt_name=prompt_name)
.join(User)
.filter(User.discord_id == discord_user_id)
.all())


class TriggeredPrompt(Base):
__tablename__ = 'TriggeredPrompt'

user_id = Column(Integer, ForeignKey('User.id'), primary_key=True)
# Name of triggered prompt. See the config.yml file for details. Represented
# as the keys of the config['prompts'] dictionary.
prompt_name = Column(String, primary_key=True)
# Time when the message was recorded as shown *on the SQL server side.* This
# time is only loosely tied to the actual send time of the message.
trigger_time = Column(
DateTime(timezone=True),
server_default=func.now(),
primary_key=True)

# Full text of the message which triggered this showing.
trigger_message = Column(String)
# Trigger string that was matched against the message.
trigger_string = Column(String)


@classmethod
def record_prompt_for_user(
cls, user, prompt_name, msg_content, trigger_string):
return TriggeredPrompt(
user_id=user.id,
prompt_name=prompt_name,
trigger_message=msg_content,
trigger_string=trigger_string)


def get_engine(db_file):
db_file = os.path.abspath(db_file)
db_path = f'sqlite:///{db_file}'
return create_engine(db_path, echo=False)
3 changes: 3 additions & 0 deletions discord_help_bot/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
discord.py
pyyaml
sqlalchemy
26 changes: 26 additions & 0 deletions docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,31 @@ it is *extremely* bad practice to send credentials like this over an unencrypted
channel like that provided by ``http``.


.. _missing_chromedriver:

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
``WebDriverException: Message: 'chromedriver' executable needs to be in PATH``
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

When creating a ``tda-api`` token using a webrowser-based method like
:func:`~tda.auth.client_from_login_flow` or :func:`~tda.auth.easy_client`, the
library must control the browser using `selenium
<https://selenium-python.readthedocs.io/>`__. This is a Python library that
sends commands to the browser to perform operations like load pages, inject
synthetic clicks, enter text, and so on. The component which is used to send
these commands is called a *driver*.

Drivers are generally not part of the standard web browser installation, meaning
you must install them manually. If you're seeing this or a similar method, you
probably haven't installed the appropriate webdriver. These drivers are
available for most of the common web browsers, including `Chrome
<https://chromedriver.chromium.org/getting-started/>`__, `Firefox
<https://github.com/mozilla/geckodriver/releases>`__, and `Safari
<https://developer.apple.com/documentation/webkit/testing_with_webdriver_in_safari>`__.
Make sure you've installed the driver *before* attempting to create a token
using ``tda-api``.


++++++++++++++++++++++
Token Parsing Failures
++++++++++++++++++++++
Expand Down Expand Up @@ -298,3 +323,4 @@ browser, you can easily copy that token file to another machine, such as your
application in the cloud. However, make sure you don't use the same token on
two machines. It is recommended to delete the token created on the
browser-capable machine as soon as it is copied to its destination.

9 changes: 8 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
version = [s.strip() for s in f.read().strip().split('=')][1]
version = version[1:-1]

from os import listdir
print(listdir())
print(listdir('discord_help_bot'))

with open('discord_help_bot/requirements.txt', 'r') as f:
discord_help_bot_requirements = f.read().split('\n')

setuptools.setup(
name='tda-api',
version=version,
Expand Down Expand Up @@ -48,7 +55,7 @@
'pytz',
'sphinx_rtd_theme',
'twine',
]
] + discord_help_bot_requirements
},
keywords='finance trading equities bonds options research',
project_urls={
Expand Down
Empty file.
Loading