From e86f6a62d0f4d038f46833d9bd11a0392a1cba36 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Mon, 27 Dec 2021 17:24:35 -0500 Subject: [PATCH 01/17] Added initial implementation --- discord-help-bot/bot.py | 177 +++++++++++++++++++++++++++++++ discord-help-bot/config.yml | 11 ++ discord-help-bot/state_db.sqlite | Bin 0 -> 20480 bytes setup.py | 1 + 4 files changed, 189 insertions(+) create mode 100644 discord-help-bot/bot.py create mode 100644 discord-help-bot/config.yml create mode 100644 discord-help-bot/state_db.sqlite diff --git a/discord-help-bot/bot.py b/discord-help-bot/bot.py new file mode 100644 index 0000000..3356d1a --- /dev/null +++ b/discord-help-bot/bot.py @@ -0,0 +1,177 @@ +import argparse +import collections +import json +import logging +import os + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import literal, create_engine +from sqlalchemy.orm import declarative_base, relationship, sessionmaker +from sqlalchemy.sql import func + +import discord +import yaml + + +################################################################################ +# Object schema + + +Base = declarative_base() + +class User(Base): + __tablename__ = 'User' + + id = Column(Integer, primary_key=True) + + addition_time = Column(DateTime(timezone=True), server_default=func.now()) + discord_username = Column(String) + discord_id = Column(Integer, index=True, unique=True) + 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})') + + +class TriggeredPrompt(Base): + __tablename__ = 'TriggeredPrompt' + + user_id = Column(Integer, ForeignKey('User.id'), primary_key=True) + prompt_name = Column(String, primary_key=True) + + trigger_time = Column(DateTime(timezone=True), server_default=func.now()) + trigger_message = Column(String) + trigger_string = Column(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) + + +################################################################################ +# Bot implementation + + +class HelperBot(discord.Client): + + def __init__(self, config_path, db_path): + super().__init__() + + with open(config_path, 'r') as f: + self.config = yaml.safe_load(f) + + self.engine = get_engine(db_path) + 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): + print(message) + + if message.author.id == self.user.id: + return + + if message.guild.name != 'tda-api': + 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): + user_id = discord_user.id + prompts_seen = (self.session + .query(User) + .filter_by(discord_id=user_id) + .join( + TriggeredPrompt, + TriggeredPrompt.user_id == User.id) + .filter_by(prompt_name=prompt_name) + .scalar()) + return prompts_seen is None + + + def record_prompt_seen( + self, prompt_name, triggered_string, discord_message): + # Get/create the user + user = (self.session + .query(User) + .filter_by(discord_id=discord_message.author.id) + .scalar()) + if not user: + user = User( + discord_username=discord_message.author.name, + discord_id=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(triggered_prompt) + + self.session.commit() + + users = (self.session + .query(User) + .filter_by(discord_id=discord_message.author.id) + .all()) + + +################################################################################ +# Main functions + + +def run_bot_main(args): + client = HelperBot(args.config, args.sqlite_db_file) + client.run(args.token) + + +def init_main(args): + def dump(sql, *multiparams, **params): + print(sql.compile(dialect=engine.dialect)) + + Base.metadata.create_all(get_engine(args.sqlite_db_file)) + + +def main(): + 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() + + if args.command == 'run': + run_bot_main(args) + elif args.command == 'init': + init_main(args) + else: + assert False + +if __name__ == '__main__': + main() diff --git a/discord-help-bot/config.yml b/discord-help-bot/config.yml new file mode 100644 index 0000000..15fa059 --- /dev/null +++ b/discord-help-bot/config.yml @@ -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 diff --git a/discord-help-bot/state_db.sqlite b/discord-help-bot/state_db.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..28a02eed452f287c9c2bd90095ce5a1475442a0f GIT binary patch literal 20480 zcmeI(PjAv-90%~HqhrFt@Ip9vc|tru7)HQo99go;9wxDLTPemJw03L7Cj7G$Gmj&_ z1@B9IOP-9!y#nJ4@Oip*E6gqN$nt$cL;F0xK7T&HULG3S`>N|Z^uy5WTRyFl`$Uq+ z7Nvv`nIFsi2uqBgh`O*y;mIxEGI`W~pIZAte{zk;?V>F2tWV=5P$## zAOHafKwu#Rrj6K2wpf(D)_v=+=NO*b?K+-g?|H-i$e+8E>pD{mMh)eK%4lw`HAnNw z#ANJvrfbutX0RQmQ*GCvT1QpaX)-Yi`ZNbt-=VJ+z5Y^}ZTHUyH+`1}Z1aJPW{c5n z)=)aCLG$&Ft~1RrMQz(qT6+c2X?RoL8IP^*WyjH`F^?H^!y!e>p5AOJ`T>2#4ru;j z%Ih>Tzo6^JuFje}nurR63RGte#-~wdZJO)w#qw3Vb|G2Vl;hd`qD0(*?HrFk_4p3e zv?l&AxHjjg%<^1?*t6af56t1xO17MlPF92a++$OWVcPEaXz1A-R$Rs|($LYG`yIxq zx7iz-yB?dPyV~U>uevClY)UKHOh%f%4RS8J6vxtRc12Z?(i^gA*|zJu!@>WhE1brJ z_a1!^L{`GKs{ya&DxgkrCn^1t@DBz`ZuOjFnSTHLdrFp)d|gud58)pa2tWV=5P$##AOHafKmY;| zfB*#Ukicpzo(T8<=kNbdN&55-Ef7nA00bZa0SG_<0uX=z1Rwwb2rQVuJ1L&1Rckf> zqwCqFk>&YcO4ex9bB`?Xhk<{*-H$}lNaPLG=kNc|NcwESU@%<>KmY;|fB*y_009U< X00Izz00d?P9>|GWBC`LF{($foVHX Date: Mon, 27 Dec 2021 17:25:55 -0500 Subject: [PATCH 02/17] Renamed directory --- {discord-help-bot => discord_help_bot}/bot.py | 0 {discord-help-bot => discord_help_bot}/config.yml | 0 .../state_db.sqlite | Bin 3 files changed, 0 insertions(+), 0 deletions(-) rename {discord-help-bot => discord_help_bot}/bot.py (100%) rename {discord-help-bot => discord_help_bot}/config.yml (100%) rename {discord-help-bot => discord_help_bot}/state_db.sqlite (100%) diff --git a/discord-help-bot/bot.py b/discord_help_bot/bot.py similarity index 100% rename from discord-help-bot/bot.py rename to discord_help_bot/bot.py diff --git a/discord-help-bot/config.yml b/discord_help_bot/config.yml similarity index 100% rename from discord-help-bot/config.yml rename to discord_help_bot/config.yml diff --git a/discord-help-bot/state_db.sqlite b/discord_help_bot/state_db.sqlite similarity index 100% rename from discord-help-bot/state_db.sqlite rename to discord_help_bot/state_db.sqlite From d493d2ffbbea0502525ce3bab9e84bdc27de4cb1 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Mon, 27 Dec 2021 20:34:08 -0500 Subject: [PATCH 03/17] Factored out models into their own module --- discord_help_bot/bot.py | 72 ++++---------------------- discord_help_bot/models.py | 86 +++++++++++++++++++++++++++++++ discord_help_bot/state_db.sqlite | Bin 20480 -> 20480 bytes 3 files changed, 96 insertions(+), 62 deletions(-) create mode 100644 discord_help_bot/models.py diff --git a/discord_help_bot/bot.py b/discord_help_bot/bot.py index 3356d1a..5e78b7f 100644 --- a/discord_help_bot/bot.py +++ b/discord_help_bot/bot.py @@ -4,52 +4,12 @@ import logging import os -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String -from sqlalchemy import literal, create_engine -from sqlalchemy.orm import declarative_base, relationship, sessionmaker -from sqlalchemy.sql import func +from sqlalchemy.orm import sessionmaker import discord import yaml - -################################################################################ -# Object schema - - -Base = declarative_base() - -class User(Base): - __tablename__ = 'User' - - id = Column(Integer, primary_key=True) - - addition_time = Column(DateTime(timezone=True), server_default=func.now()) - discord_username = Column(String) - discord_id = Column(Integer, index=True, unique=True) - 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})') - - -class TriggeredPrompt(Base): - __tablename__ = 'TriggeredPrompt' - - user_id = Column(Integer, ForeignKey('User.id'), primary_key=True) - prompt_name = Column(String, primary_key=True) - - trigger_time = Column(DateTime(timezone=True), server_default=func.now()) - trigger_message = Column(String) - trigger_string = Column(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) +from models import Base, User, TriggeredPrompt, get_engine ################################################################################ @@ -91,28 +51,18 @@ async def on_message(self, message): def should_trigger_for_prompt(self, prompt_name, discord_user): user_id = discord_user.id - prompts_seen = (self.session - .query(User) - .filter_by(discord_id=user_id) - .join( - TriggeredPrompt, - TriggeredPrompt.user_id == User.id) - .filter_by(prompt_name=prompt_name) - .scalar()) + prompts_seen = User.get_triggered_prompt_for_user( + self.session, prompt_name, user_id) return prompts_seen is None def record_prompt_seen( self, prompt_name, triggered_string, discord_message): # Get/create the user - user = (self.session - .query(User) - .filter_by(discord_id=discord_message.author.id) - .scalar()) + user = User.get_user_with_id(self.session, discord_message.author.id) if not user: - user = User( - discord_username=discord_message.author.name, - discord_id=discord_message.author.id) + user = User.new_user( + discord_message.author.name, discord_message.author.id) self.session.add(user) self.session.flush() @@ -122,14 +72,12 @@ def record_prompt_seen( prompt_name=prompt_name, trigger_message=discord_message.content, trigger_string=triggered_string) - self.session.add(triggered_prompt) + self.session.add(TriggeredPrompt.record_prompt_for_user( + user, prompt_name, discord_message.content, triggered_string)) self.session.commit() - users = (self.session - .query(User) - .filter_by(discord_id=discord_message.author.id) - .all()) + return triggered_prompt ################################################################################ diff --git a/discord_help_bot/models.py b/discord_help_bot/models.py new file mode 100644 index 0000000..1a01a2c --- /dev/null +++ b/discord_help_bot/models.py @@ -0,0 +1,86 @@ +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) + + addition_time = Column(DateTime(timezone=True), server_default=func.now()) + discord_username = Column(String) + discord_id = Column(Integer, index=True, unique=True) + 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): + return User( + discord_username=discord_user_name, + discord_id=discord_user_id) + + + @classmethod + def get_user_with_id(cls, session, discord_user_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): + return (session + .query(User) + .filter_by(discord_id=discord_user_id) + .join( + TriggeredPrompt, + TriggeredPrompt.user_id == User.id) + .filter_by(prompt_name=prompt_name) + .scalar()) + + +class TriggeredPrompt(Base): + __tablename__ = 'TriggeredPrompt' + + user_id = Column(Integer, ForeignKey('User.id'), primary_key=True) + prompt_name = Column(String, primary_key=True) + + trigger_time = Column(DateTime(timezone=True), server_default=func.now()) + trigger_message = Column(String) + 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) + + + diff --git a/discord_help_bot/state_db.sqlite b/discord_help_bot/state_db.sqlite index 28a02eed452f287c9c2bd90095ce5a1475442a0f..2faac45e383e7aa07e56608ca79a4c4f14aea88e 100644 GIT binary patch delta 41 mcmZozz}T>Wal<{ZQw+HP delta 41 mcmZozz}T>Wal{gD<{a{S4Cp From a65969634098ae2d4330c536bb2d497c6d3f5377 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Mon, 27 Dec 2021 20:45:51 -0500 Subject: [PATCH 04/17] Added comments --- discord_help_bot/bot.py | 2 ++ discord_help_bot/models.py | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/discord_help_bot/bot.py b/discord_help_bot/bot.py index 5e78b7f..b2b38e3 100644 --- a/discord_help_bot/bot.py +++ b/discord_help_bot/bot.py @@ -50,6 +50,7 @@ async def on_message(self, 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) @@ -58,6 +59,7 @@ def should_trigger_for_prompt(self, prompt_name, discord_user): 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_id(self.session, discord_message.author.id) if not user: diff --git a/discord_help_bot/models.py b/discord_help_bot/models.py index 1a01a2c..d72c225 100644 --- a/discord_help_bot/models.py +++ b/discord_help_bot/models.py @@ -17,9 +17,15 @@ class User(Base): 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') @@ -31,6 +37,7 @@ def __repr__(self): @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) @@ -38,6 +45,7 @@ def new_user(cls, discord_user_name, discord_user_id): @classmethod def get_user_with_id(cls, session, discord_user_id): + 'Get user by ID' return (session .query(User) .filter_by(discord_id=discord_user_id) @@ -46,6 +54,8 @@ def get_user_with_id(cls, session, discord_user_id): @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(User) .filter_by(discord_id=discord_user_id) @@ -60,10 +70,16 @@ 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()) + # 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) @@ -81,6 +97,3 @@ 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) - - - From 6d07702a215437804d7b755c8faf9a518f4ff3d5 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Mon, 27 Dec 2021 20:56:35 -0500 Subject: [PATCH 05/17] Added test and coverage scaffolding --- Makefile | 2 +- tests/discord_help_bot/__init__.py | 0 tests/discord_help_bot/bot_test.py | 5 +++++ tox.ini | 2 +- 4 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 tests/discord_help_bot/__init__.py create mode 100644 tests/discord_help_bot/bot_test.py diff --git a/Makefile b/Makefile index 1124677..55af457 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/tests/discord_help_bot/__init__.py b/tests/discord_help_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/discord_help_bot/bot_test.py b/tests/discord_help_bot/bot_test.py new file mode 100644 index 0000000..740cfdb --- /dev/null +++ b/tests/discord_help_bot/bot_test.py @@ -0,0 +1,5 @@ +import unittest + +class HelperBotTest(unittest.TestCase): + def test_whatever(self): + assert True diff --git a/tox.ini b/tox.ini index 5dbde62..b034fe6 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ setenv = TESTPATH=tests/ RCFILE=setup.cfg commands = - coverage run --rcfile={env:RCFILE} --source=tda -p -m pytest {env:TESTPATH} + coverage run --rcfile={env:RCFILE} --source=tda,discord_help_bot -p -m pytest {env:TESTPATH} [testenv:coverage] skip_install = true From a74862c2b3a05657d1612ad28e7e5b68e19d277b Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 13:50:41 -0500 Subject: [PATCH 06/17] Added bot tests --- discord_help_bot/bot.py | 33 ++--- discord_help_bot/models.py | 19 +-- tests/discord_help_bot/bot_test.py | 190 ++++++++++++++++++++++++++++- 3 files changed, 209 insertions(+), 33 deletions(-) diff --git a/discord_help_bot/bot.py b/discord_help_bot/bot.py index b2b38e3..ef9fe7b 100644 --- a/discord_help_bot/bot.py +++ b/discord_help_bot/bot.py @@ -18,13 +18,11 @@ class HelperBot(discord.Client): - def __init__(self, config_path, db_path): + def __init__(self, config, db_engine): super().__init__() - with open(config_path, 'r') as f: - self.config = yaml.safe_load(f) - - self.engine = get_engine(db_path) + self.config = config + self.engine = db_engine self.session = sessionmaker(bind=self.engine)() @@ -32,14 +30,9 @@ async def on_ready(self): print(f'Logged in as {self.user} (ID: {self.user.id})') async def on_message(self, message): - print(message) - if message.author.id == self.user.id: return - if message.guild.name != 'tda-api': - return - for prompt_name, prompt in self.config['prompts'].items(): for trigger in prompt['triggers']: if (trigger in message.content.lower() @@ -54,14 +47,15 @@ def should_trigger_for_prompt(self, prompt_name, discord_user): user_id = discord_user.id prompts_seen = User.get_triggered_prompt_for_user( self.session, prompt_name, user_id) - return prompts_seen is None + 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_id(self.session, discord_message.author.id) + 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) @@ -87,15 +81,12 @@ def record_prompt_seen( def run_bot_main(args): - client = HelperBot(args.config, args.sqlite_db_file) - client.run(args.token) - + with open(args.config, 'r') as f: + config = yaml.safe_load(f) + engine = get_engine(args.sqlite_db_file) -def init_main(args): - def dump(sql, *multiparams, **params): - print(sql.compile(dialect=engine.dialect)) - - Base.metadata.create_all(get_engine(args.sqlite_db_file)) + client = HelperBot(config, engine) + client.run(args.token) def main(): @@ -119,7 +110,7 @@ def main(): if args.command == 'run': run_bot_main(args) elif args.command == 'init': - init_main(args) + Base.metadata.create_all(get_engine(args.sqlite_db_file)) else: assert False diff --git a/discord_help_bot/models.py b/discord_help_bot/models.py index d72c225..1e299d7 100644 --- a/discord_help_bot/models.py +++ b/discord_help_bot/models.py @@ -44,7 +44,7 @@ def new_user(cls, discord_user_name, discord_user_id): @classmethod - def get_user_with_id(cls, session, discord_user_id): + def get_user_with_discord_id(cls, session, discord_user_id): 'Get user by ID' return (session .query(User) @@ -57,13 +57,11 @@ 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(User) - .filter_by(discord_id=discord_user_id) - .join( - TriggeredPrompt, - TriggeredPrompt.user_id == User.id) + .query(TriggeredPrompt) .filter_by(prompt_name=prompt_name) - .scalar()) + .join(User) + .filter(User.discord_id == discord_user_id) + .all()) class TriggeredPrompt(Base): @@ -73,10 +71,13 @@ class TriggeredPrompt(Base): # 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()) + 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. diff --git a/tests/discord_help_bot/bot_test.py b/tests/discord_help_bot/bot_test.py index 740cfdb..51ae830 100644 --- a/tests/discord_help_bot/bot_test.py +++ b/tests/discord_help_bot/bot_test.py @@ -1,5 +1,189 @@ +import os +import sys +import tempfile import unittest -class HelperBotTest(unittest.TestCase): - def test_whatever(self): - assert True +import asynctest + +from unittest.mock import patch, MagicMock +from sqlalchemy.orm import sessionmaker + +sys.path.append(os.path.join(os.getcwd(), 'discord_help_bot')) +print(os.path.join(os.getcwd(), 'discord_help_bot')) + +from discord_help_bot.bot import HelperBot +from discord_help_bot.models import Base, User, TriggeredPrompt, get_engine + + +class DummyDiscordUser: + def __init__(self, discord_user_id, discord_username): + self.id = discord_user_id + self.name = discord_username + +class DummyDiscordMessage(MagicMock): + @classmethod + def create(self, dummy_discord_author, content): + message = DummyDiscordMessage() + + message.author = dummy_discord_author + message.content = content + + return message + + async def reply(self, reply_str): + # self.reply is a mock method created by MagicMock + self.sync_reply(reply_str) + + +TEST_BOT_USER_ID = 10001 +TEST_BOT_USER_NAME = 'test-bot' + + +class HelperBotTest(asynctest.TestCase): + + def setUp(self): + self.config = { + 'prompts': { + 'prompt-1-name': { + 'triggers': [ + 'prompt 1 trigger phrase 1', + 'prompt 1 trigger phrase 2', + ], + 'response': 'prompt 1 response' + }, + 'prompt-2-name': { + 'triggers': [ + 'prompt 2 trigger phrase 1', + 'prompt 2 trigger phrase 2', + ], + 'response': 'prompt 2 response' + }, + } + } + + self.tmp_db = tempfile.NamedTemporaryFile() + self.engine = get_engine(self.tmp_db.name) + self.helper = HelperBot(self.config, self.engine) + self.session = sessionmaker(bind=self.engine)() + + Base.metadata.create_all(self.engine) + + self.user = DummyDiscordUser(TEST_BOT_USER_ID, TEST_BOT_USER_NAME) + # XXX HACK: Reaching into the discord.py Client object because the user + # field is an attribute that doesn't let itself be deleted or directly + # modified. + self.helper._connection.user = self.user + + + def add_user(self, username, discord_id): + User.new_user(username, discord_id) + + + async def test_message_no_trigger(self): + message = DummyDiscordMessage.create( + DummyDiscordUser(1, 'username'), + 'unremarkable message') + + await self.helper.on_message(message) + + message.sync_reply.assert_not_called() + + + async def test_message_new_user(self): + message = DummyDiscordMessage.create( + DummyDiscordUser(1001, 'username'), + 'message containing prompt 1 trigger phrase 1') + + await self.helper.on_message(message) + + message.sync_reply.assert_called_once_with('prompt 1 response') + + user = User.get_user_with_discord_id(self.session, 1001) + self.assertEqual(user.discord_username, 'username') + self.assertEqual(user.discord_id, 1001) + + prompts = User.get_triggered_prompt_for_user( + self.session, 'prompt-1-name', + 1001) + self.assertEqual(1, len(prompts)) + prompt = prompts[0] + + self.assertEqual(prompt.user_id, user.id) + self.assertEqual(prompt.prompt_name, 'prompt-1-name') + self.assertEqual( + prompt.trigger_message, + 'message containing prompt 1 trigger phrase 1') + self.assertEqual(prompt.trigger_string, 'prompt 1 trigger phrase 1') + + + async def test_message_message_seen_twice(self): + # First message + message = DummyDiscordMessage.create( + DummyDiscordUser(1001, 'username'), + 'message containing prompt 1 trigger phrase 1') + + await self.helper.on_message(message) + message.sync_reply.assert_called_once_with('prompt 1 response') + + # Second message + message = DummyDiscordMessage.create( + DummyDiscordUser(1001, 'username'), + 'message containing prompt 1 trigger phrase 1') + + await self.helper.on_message(message) + message.sync_reply.assert_not_called() + + + async def test_message_multiple_users(self): + # User 1 triggers + message = DummyDiscordMessage.create( + DummyDiscordUser(1001, 'username'), + 'message containing prompt 1 trigger phrase 1') + + await self.helper.on_message(message) + + message.sync_reply.assert_called_once_with('prompt 1 response') + + user_1001 = User.get_user_with_discord_id(self.session, 1001) + self.assertEqual(user_1001.discord_username, 'username') + self.assertEqual(user_1001.discord_id, 1001) + + prompts = User.get_triggered_prompt_for_user( + self.session, 'prompt-1-name', + 1001) + self.assertEqual(1, len(prompts)) + prompt = prompts[0] + + self.assertEqual(prompt.user_id, user_1001.id) + self.assertEqual(prompt.prompt_name, 'prompt-1-name') + self.assertEqual( + prompt.trigger_message, + 'message containing prompt 1 trigger phrase 1') + self.assertEqual(prompt.trigger_string, 'prompt 1 trigger phrase 1') + + + # User 2 triggers + message = DummyDiscordMessage.create( + DummyDiscordUser(1002, 'username'), + 'message containing prompt 2 trigger phrase 1') + + await self.helper.on_message(message) + + message.sync_reply.assert_called_once_with('prompt 2 response') + + user_1002 = User.get_user_with_discord_id(self.session, 1002) + self.assertEqual(user_1002.discord_username, 'username') + self.assertEqual(user_1002.discord_id, 1002) + + prompts = User.get_triggered_prompt_for_user( + self.session, 'prompt-2-name', + 1002) + self.assertEqual(1, len(prompts)) + prompt = prompts[0] + + self.assertEqual(prompt.user_id, user_1002.id) + self.assertEqual(prompt.prompt_name, 'prompt-2-name') + self.assertEqual( + prompt.trigger_message, + 'message containing prompt 2 trigger phrase 1') + self.assertEqual(prompt.trigger_string, 'prompt 2 trigger phrase 1') From 110485a115f3a9209810bb6fe12e10d676b8858f Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 13:52:30 -0500 Subject: [PATCH 07/17] Adds dependency on discord --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f13c7a8..0080fc5 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ 'asynctest', 'colorama', 'coverage', + 'discord.py', 'tox', 'nose', 'pytest', From 3125d580dc13da19cbcbc8333afddb2a5150d06f Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 13:55:20 -0500 Subject: [PATCH 08/17] Also added dependency on pyyaml --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 0080fc5..ce1dea2 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ 'nose', 'pytest', 'pytz', + 'pyyaml', 'sphinx_rtd_theme', 'sqlalchemy', 'twine', From a545d9d7b2055b8d854361c26078e5546e3f59d9 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 14:04:41 -0500 Subject: [PATCH 09/17] Tweaked coverage and added some missing tests --- discord_help_bot/bot.py | 11 ++++++----- tests/discord_help_bot/bot_test.py | 14 +++++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/discord_help_bot/bot.py b/discord_help_bot/bot.py index ef9fe7b..8d04963 100644 --- a/discord_help_bot/bot.py +++ b/discord_help_bot/bot.py @@ -3,6 +3,7 @@ import json import logging import os +import sys from sqlalchemy.orm import sessionmaker @@ -80,7 +81,7 @@ def record_prompt_seen( # Main functions -def run_bot_main(args): +def run_bot_main(args): # pragma: no cover with open(args.config, 'r') as f: config = yaml.safe_load(f) engine = get_engine(args.sqlite_db_file) @@ -89,7 +90,7 @@ def run_bot_main(args): client.run(args.token) -def main(): +def main(argv): parser = argparse.ArgumentParser('FAQ-based helper bot for tda-api') subparsers = parser.add_subparsers(metavar='', dest='command') @@ -105,7 +106,7 @@ def main(): init_parser.add_argument('--sqlite_db_file', required=True, help= 'Location of sqlite3 database file') - args = parser.parse_args() + args = parser.parse_args(argv) if args.command == 'run': run_bot_main(args) @@ -114,5 +115,5 @@ def main(): else: assert False -if __name__ == '__main__': - main() +if __name__ == '__main__': # pragma: no cover + main(sys.argv[1:]) diff --git a/tests/discord_help_bot/bot_test.py b/tests/discord_help_bot/bot_test.py index 51ae830..1bfe631 100644 --- a/tests/discord_help_bot/bot_test.py +++ b/tests/discord_help_bot/bot_test.py @@ -41,7 +41,7 @@ async def reply(self, reply_str): class HelperBotTest(asynctest.TestCase): - def setUp(self): + async def setUp(self): self.config = { 'prompts': { 'prompt-1-name': { @@ -63,22 +63,30 @@ def setUp(self): self.tmp_db = tempfile.NamedTemporaryFile() self.engine = get_engine(self.tmp_db.name) - self.helper = HelperBot(self.config, self.engine) self.session = sessionmaker(bind=self.engine)() - Base.metadata.create_all(self.engine) + self.helper = HelperBot(self.config, self.engine) self.user = DummyDiscordUser(TEST_BOT_USER_ID, TEST_BOT_USER_NAME) # XXX HACK: Reaching into the discord.py Client object because the user # field is an attribute that doesn't let itself be deleted or directly # modified. self.helper._connection.user = self.user + await self.helper.on_ready() + def add_user(self, username, discord_id): User.new_user(username, discord_id) + async def test_ignore_messages_by_bot(self): + message = DummyDiscordMessage.create( + self.user, 'prompt 1 trigger phrase 1') + await self.helper.on_message(message) + message.sync_reply.assert_not_called() + + async def test_message_no_trigger(self): message = DummyDiscordMessage.create( DummyDiscordUser(1, 'username'), From 5604f2f3652f4c5b98e1ba3b5f1222d1510c9f0e Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 15:24:24 -0500 Subject: [PATCH 10/17] Added main method tests --- discord_help_bot/state_db.sqlite | Bin 20480 -> 20480 bytes tests/discord_help_bot/bot_test.py | 95 +++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/discord_help_bot/state_db.sqlite b/discord_help_bot/state_db.sqlite index 2faac45e383e7aa07e56608ca79a4c4f14aea88e..c4fbaaa8a3cd045dd05addb5748bed900d24a027 100644 GIT binary patch delta 142 zcmZozz}T>Wae_1>^F$eEQDz3c-~?X2D-0~WrVRWJe0O+F`KE4cJjlb<6wAymuC2}3 zQaCw`cQdDgpMQvgU#O4IWKO;)9vy{}qRjO4)S~#3%*}~>iy4_11U3sQJm#M`L7a_? N0SFLWUQ{lsEC6MVB%lBQ delta 299 zcmZozz}T>Wae_1>%S0JxQ5FWh-~?X2D-0~WJ`DU0e0O+#_@-_a6u7~|)#S#^F0QT3 z*y23do_F))-@H+qllc}hvN7`CV&K08RC9sfK!uT&!B~{d$iT=@*U(7U$U?!u(8}1z z%G4k+C$)k_VR6}~CI%)h1_lO3{x1ytUx2z^@r!XYvvI=Yc)@ZD82A?e<)-mx#WAvR zS{qwimtrPwC@(bj5G_U KH7Al Date: Thu, 30 Dec 2021 15:27:19 -0500 Subject: [PATCH 11/17] Added no covers --- discord_help_bot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord_help_bot/bot.py b/discord_help_bot/bot.py index 8d04963..7c2be41 100644 --- a/discord_help_bot/bot.py +++ b/discord_help_bot/bot.py @@ -81,7 +81,7 @@ def record_prompt_seen( # Main functions -def run_bot_main(args): # pragma: no cover +def run_bot_main(args): with open(args.config, 'r') as f: config = yaml.safe_load(f) engine = get_engine(args.sqlite_db_file) @@ -112,7 +112,7 @@ def main(argv): run_bot_main(args) elif args.command == 'init': Base.metadata.create_all(get_engine(args.sqlite_db_file)) - else: + else: # pragma: no cover assert False if __name__ == '__main__': # pragma: no cover From dacdf91954e4f819e20e1ba0a4f4cb37d0d73451 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 17:06:51 -0500 Subject: [PATCH 12/17] Dockerized things --- discord_help_bot/bot.py | 2 +- discord_help_bot/state_db.sqlite | Bin 20480 -> 0 bytes setup.py | 8 ++++---- 3 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 discord_help_bot/state_db.sqlite diff --git a/discord_help_bot/bot.py b/discord_help_bot/bot.py index 7c2be41..28a566b 100644 --- a/discord_help_bot/bot.py +++ b/discord_help_bot/bot.py @@ -116,4 +116,4 @@ def main(argv): assert False if __name__ == '__main__': # pragma: no cover - main(sys.argv[1:]) + main(sys.argv[1:]) diff --git a/discord_help_bot/state_db.sqlite b/discord_help_bot/state_db.sqlite deleted file mode 100644 index c4fbaaa8a3cd045dd05addb5748bed900d24a027..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmeI%O>f#T7zc2tu+cKXdO=(tQ zezP6-VP9<<_7c19}+56M_@7i(waofXahX4d1009U<00Izz00bcL-w8Zi zmMYD5Tl;yN`B&GWNTS&+Ou}HC#PgeMqgw7d+!UOIc{bprZ}jTasva!UF!7>*EL-q% z?vOnaWV?ewhpGoR`JCRupNI6tbh@9+_5SQ{a4(DG13ei?SVK-d-Z$NWpjOv)9BvCw zwN8aO92;x17o+E4n)W34W5*hr&INtu7u0%MdxusQ+M!~wT9u*3 z^hXZ2&TTcO7-Epa`&_QB%crEf(m-NFfl)P1%5R&Kw#K4G5Zz-KW z_Kr3h)R}c(?U}2+O7p0p-M`DnMR%Tx;RR9pJx&7Yt*WJ`?6|fyakvYTR}~!?!EP-tEN2DTaxStL6k-D;@`8VOp9eKW4s{*AOHafKmY;| NfB*y_009U<;158K<~9HT diff --git a/setup.py b/setup.py index ce1dea2..ccc8f38 100644 --- a/setup.py +++ b/setup.py @@ -8,6 +8,9 @@ version = [s.strip() for s in f.read().strip().split('=')][1] version = version[1:-1] +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, @@ -42,16 +45,13 @@ 'asynctest', 'colorama', 'coverage', - 'discord.py', 'tox', 'nose', 'pytest', 'pytz', - 'pyyaml', 'sphinx_rtd_theme', - 'sqlalchemy', 'twine', - ] + ] + discord_help_bot_requirements }, keywords='finance trading equities bonds options research', project_urls={ From d1e8d46c9292f05e1e27f9c92a242ca98d084f5c Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 17:07:37 -0500 Subject: [PATCH 13/17] Forgot some files --- discord_help_bot/.gitignore | 1 + discord_help_bot/Dockerfile | 26 ++++++++++++++++++++++++++ discord_help_bot/requirements.txt | 3 +++ 3 files changed, 30 insertions(+) create mode 100644 discord_help_bot/.gitignore create mode 100644 discord_help_bot/Dockerfile create mode 100644 discord_help_bot/requirements.txt diff --git a/discord_help_bot/.gitignore b/discord_help_bot/.gitignore new file mode 100644 index 0000000..9b1dffd --- /dev/null +++ b/discord_help_bot/.gitignore @@ -0,0 +1 @@ +*.sqlite diff --git a/discord_help_bot/Dockerfile b/discord_help_bot/Dockerfile new file mode 100644 index 0000000..62b2de9 --- /dev/null +++ b/discord_help_bot/Dockerfile @@ -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"] diff --git a/discord_help_bot/requirements.txt b/discord_help_bot/requirements.txt new file mode 100644 index 0000000..d5541e6 --- /dev/null +++ b/discord_help_bot/requirements.txt @@ -0,0 +1,3 @@ +discord.py +pyyaml +sqlalchemy From a7a719f83fa2bdbec3887f6ba03e433f1da4266c Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 17:25:50 -0500 Subject: [PATCH 14/17] Added README --- discord_help_bot/README.rst | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 discord_help_bot/README.rst diff --git a/discord_help_bot/README.rst b/discord_help_bot/README.rst new file mode 100644 index 0000000..6b94560 --- /dev/null +++ b/discord_help_bot/README.rst @@ -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 init --sqlite_db_file + /sqlite/state.sqlite_db + +Start the bot: + +.. code-block:: bash + docker run -t -v discord_bot_state:/state run --token + From 30b923d6d5b00ef0084520376a32b08c56588745 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 17:34:31 -0500 Subject: [PATCH 15/17] Debugging missing requirements.txt file in test actions --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index ccc8f38..49f47ec 100644 --- a/setup.py +++ b/setup.py @@ -8,6 +8,10 @@ version = [s.strip() for s in f.read().strip().split('=')][1] version = version[1:-1] +from os import listdir +print(os.listdir()) +print(os.listdir('discord_help_bot')) + with open('discord_help_bot/requirements.txt', 'r') as f: discord_help_bot_requirements = f.read().split('\n') From 1c9cbf0a8cce418619830f1abc6184dd53049e76 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Thu, 30 Dec 2021 17:36:15 -0500 Subject: [PATCH 16/17] . --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 49f47ec..914c0dc 100644 --- a/setup.py +++ b/setup.py @@ -9,8 +9,8 @@ version = version[1:-1] from os import listdir -print(os.listdir()) -print(os.listdir('discord_help_bot')) +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') From ac33887fd9f38a79aa73ec2173ba9d87c4dd21d1 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Tue, 4 Jan 2022 20:13:43 -0500 Subject: [PATCH 17/17] Added FAQ section on missing chromedriver --- docs/auth.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/auth.rst b/docs/auth.rst index 2a1a5df..13a4b41 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -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 +`__. 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 +`__, `Firefox +`__, and `Safari +`__. +Make sure you've installed the driver *before* attempting to create a token +using ``tda-api``. + + ++++++++++++++++++++++ Token Parsing Failures ++++++++++++++++++++++ @@ -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. +