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

Help module #300

Merged
merged 28 commits into from
Jun 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4408846
list modules works
Jun 17, 2023
592bbec
s, help commands added
Jun 17, 2023
4bcb554
fixed message_id problems
Jun 17, 2023
9609ce0
changed help capabilities dict type to dict[tuple[str, ...], tuples[s…
Jun 17, 2023
6671e3c
added help for all command types in Questions
Jun 17, 2023
d512267
moved parsing module names from message text to utilities, help with …
Jun 17, 2023
765f024
moved ModuleHelp to utilities.help_utils.py and adjusted Questions mo…
Jun 17, 2023
bee23bd
cleaned up help_utils methods and added help command to HelpModule
Jun 17, 2023
14f7d87
fixed parsing command name and finished HelpModule docstring
Jun 17, 2023
c93e68a
moved test_longmessage to TestModule
Jun 17, 2023
d12f369
changed message.author.name to message.author.display_name except whe…
Jun 17, 2023
664b25b
added guard in discord.py around bot_dev_channel_id being None
Jun 17, 2023
6e51b70
changed '|' to ', ' for concatenating alt names
Jun 17, 2023
c5f980b
new QuestionSetter docstring
Jun 17, 2023
eab7bcf
changed STAMPY_HELP_MSG
Jun 18, 2023
562e581
wrote docstring for TestModule
Jun 19, 2023
0c4677b
reformatted help docstrings
Jun 22, 2023
ba29c0a
refactored help_utils and build_help
Jun 22, 2023
da0fb36
fixed mergeconflict
Jun 22, 2023
363c048
debugged getting list of modules
Jun 22, 2023
699e841
updated FILE_HEADER in build_help
Jun 23, 2023
98809b8
refactored cb_help and wrote tests for HelpModule
Jun 23, 2023
9cdf7bd
minor fixes
Jun 23, 2023
4f343eb
added tests for helpless modules and fixed regex in helped modules
Jun 23, 2023
7dd2f0d
refactored helpless_module_tests to use islice and sorted modules in …
Jun 23, 2023
29de7d9
fixed issue #297
Jun 23, 2023
87ce9cc
removed breakpoints
Jun 23, 2023
5a2513f
minor fixes of types and user names
Jun 23, 2023
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Not required:
- `BOT_DEV_ROLES`: list of roles representing bot devs.
- `BOT_DEV_IDS`: list of user ids of bot devs. You may want to include `BOT_VIP_IDS` here.
- `BOT_CONTROL_CHANNEL_IDS`: list of channels where control commands are accepted.
- `BOT_PRIVATE_CHANNEL_IDS`: single channel where private Stampy status updates are sent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! 😅

- `BOT_PRIVATE_CHANNEL_ID`: single channel where private Stampy status updates are sent
- `CODA_API_TOKEN`: token to access Coda. Without it, modules `Questions` and `QuestionSetter` will not be available and `StampyControls` will have limited functionality.
- `BOT_REBOOT`: how Stampy reboots himself. Unset, he only quits, expecting an external `while true` loop (like in `runstampy`/Dockerfile). Set to `exec` he will try to relaunch himself from his own CLI arguments.
- `STOP_ON_ERROR`: Dockerfile/`runstampy` only, unset `BOT_REBOOT` only. If defined, will only restart Stampy when he gets told to reboot, returning exit code 42. Any other exit code will cause the script to just stop.
Expand Down
10 changes: 5 additions & 5 deletions api/coda.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def __init__(self):
if is_in_testing_mode():
return

self.coda = Coda(coda_api_token) #type:ignore
self.coda = Coda(coda_api_token) # type:ignore
self.reload_questions_cache()
self.reload_users_cache()
self.status_shorthand_dict = self._get_status_shorthand_dict()
Expand Down Expand Up @@ -438,7 +438,7 @@ async def get_response_text_and_why(

# QuestionGDocLinks
if query[0] == "GDocLinks":
why = f"{message.author.name} queried for questions matching one or more GDoc links"
why = f"{message.author.display_name} queried for questions matching one or more GDoc links"
if not questions:
return ("These links don't lead to any questions", why + FOUND_NOTHING)
text = "Here it is:" if len(questions) == 1 else "Here they are:"
Expand All @@ -447,15 +447,15 @@ async def get_response_text_and_why(
# QuestionTitle
if query[0] == "Title":
question_title = query[1]
why = f'{message.author.name} asked for a question with title matching "{question_title}"'
why = f'{message.author.display_name} asked for a question with title matching "{question_title}"'
if not questions:
return ("I found no question matching that title", why + FOUND_NOTHING)
return "Here it is:", why

# QuestionLast
if query[0] == "Last":
mention = query[1]
why = f"{message.author.name} asked about the last question"
why = f"{message.author.display_name} asked about the last question"
if not questions:
text = (
f'What do you mean by "{mention}"?'
Expand All @@ -475,7 +475,7 @@ async def get_response_text_and_why(

_status, _tag, limit = query[1]

why = f"{message.author.name} asked me for questions{FOUND_NOTHING}"
why = f"{message.author.display_name} asked me for questions{FOUND_NOTHING}"
if not questions:
return "I found no questions", why
if len(questions) == limit == 1:
Expand Down
89 changes: 46 additions & 43 deletions build_help.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,67 @@
"""Run this script to update help.md file"""
import os
from pathlib import Path
import re
from textwrap import dedent
from typing import Optional

from utilities.help_utils import ModuleHelp

HELP_FILE_START_TEXT = """\
# Stampy commands
MODULES_PATH = Path("modules/")

This file ~~lists~~ *will list (at some point [WIP])* all available commands for Stampy, divided according to which module handles them.
ModuleName = Docstring = str

Whenever you add a new feature to Stampy or meaningfully modify some feature in a way that may alter how it acts, please update this file and test manually whether Stampy's behavior follows the specification."""
FILE_HEADER = dedent(
"""\
# Stampy Module & Command Help

This file was auto-generated from file-level docstrings in `modules` directory. If you think it's out of sync with docstrings, re-generate it by calling `python build_help.py`. If docstrings are out of date with code, feel free to edit them or nag somebody with a `@Stampy dev` on the server.

"""
)

_re_module_name = re.compile(r"(?:\nclass\s)(\w+)(?:\(Module\))")

ModuleName = DocString = str
def extract_docstring(code: str) -> Optional[Docstring]:
if not (code.startswith('"""') and '"""' in code[3:]):
return
docstring_end_pos = code.find('"""', 3)
assert docstring_end_pos != -1
docstring = code[3:docstring_end_pos].strip()
assert docstring
return docstring


def get_module_docstring(module_fname: str) -> Optional[tuple[ModuleName, DocString]]:
"""Get the name of the `Module` defined in the file and its docstring."""
with open(f"modules/{module_fname}", "r", encoding="utf-8") as f:
code = f.read()
_re_module_name = re.compile(r"(?<=class\s)\w+(?=\(Module\):)", re.I)

# If the first line doesn't start with docstring, skip
if '"""' not in code.split("\n")[0]:
return
# If there is no class that inherits from `Module`, skip
if not (match := _re_module_name.search(code)):
return

# Extract docstring
docstring_start = code.find('"""')
docstring_end = code.find('"""', docstring_start + 3)
docstring = code[docstring_start + 3 : docstring_end].strip()
def extract_module_name(code: str) -> Optional[ModuleName]:
if match := _re_module_name.search(code):
return match.group()


# Extract module name
module_name = match.group(1)
return module_name, docstring
def load_modules_with_docstrings() -> dict[ModuleName, Docstring]:
modules_with_docstrings = {}
for fname in os.listdir(MODULES_PATH):
if fname.startswith("_") or fname == "module.py":
continue
with open(MODULES_PATH / fname, "r", encoding="utf-8") as f:
code = f.read()
if (module_name := extract_module_name(code)) and (
docstring := extract_docstring(code)
):
modules_with_docstrings[module_name] = docstring
return modules_with_docstrings


def main() -> None:
# get module filenames
module_fnames = [
fname
for fname in os.listdir("modules")
if fname.endswith(".py") and fname != "__init__.py"
]

# build help file text
help_file_text = HELP_FILE_START_TEXT
for fname in module_fnames:
if result := get_module_docstring(fname):
module_name, docstring = result
help_file_text += f"\n\n## {module_name}\n\n{docstring}"

# final newline for markdown pedancy
help_file_text += "\n"

# save it
modules_with_docstrings = load_modules_with_docstrings()
helps = []
for module_name, docstring in modules_with_docstrings.items():
help = ModuleHelp.from_docstring(module_name, docstring)
if not help.empty:
helps.append(help.get_module_help(markdown=True))
help_txt = FILE_HEADER + "\n\n".join(helps)
with open("help.md", "w", encoding="utf-8") as f:
f.write(help_file_text)
f.write(help_txt)


if __name__ == "__main__":
Expand Down
9 changes: 6 additions & 3 deletions modules/Eliza.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,18 @@ def analyze(self, statement: str) -> str:
def process_message(self, message: ServiceMessage) -> Response:
if text := self.is_at_me(message):
# ELIZA can respond to almost anything, so it only talks if nothing else has, hence 1 confidence
if text.startswith("is ") and text[-1] != "?": # stampy is x becomes "you are x"
if (
text.startswith("is ") and text[-1] != "?"
): # stampy is x becomes "you are x"
text = "you are " + text.partition(" ")[2]
result = self.dereference(
self.analyze(text).replace("<<", "{{").replace(">>", "}}"), message.author.name
self.analyze(text).replace("<<", "{{").replace(">>", "}}"),
message.author.display_name,
)
if result:
return Response(
confidence=1,
text=result,
why=f"{message.author.name} said '{text}', and ELIZA responded '{result}'" ,
why=f"{message.author.display_name} said '{text}', and ELIZA responded '{result}'",
)
return Response()
3 changes: 1 addition & 2 deletions modules/Factoids.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def __init__(self):
# dict of room ids to factoid: (text, value, verb) tuples
self.prev_factoid = {}

def process_message(self, message: ServiceMessage):
def process_message(self, message: ServiceMessage) -> Response:
self.who = message.author.name
self.utils.people.add(self.who)
result = ""
Expand Down Expand Up @@ -217,7 +217,6 @@ def __str__(self):
@property
def test_cases(self):
return [

self.create_integration_test(
test_message="remember chriscanal is the person who wrote this test",
expected_response=f'Ok {Utilities.get_instance().discord_user.name}, remembering that "chriscanal" is "the person who wrote this test"',
Expand Down
125 changes: 125 additions & 0 deletions modules/HelpModule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
Helps you interact with me

List modules
List what modules I have + short descriptions
`s, list modules`

Help
You can ask me for help with (1) a particular module or (2) a particular command defined on a module
`s, help <module-name>` - returns description of a module and lists all of its commands
`s, help <command-name>` - returns description of a command
"""
from itertools import islice
import re
from textwrap import dedent

from modules.module import IntegrationTest, Module, Response
from utilities.help_utils import ModuleHelp
from utilities.serviceutils import ServiceMessage


class HelpModule(Module):
STAMPY_HELP_MSG = dedent(
"""\
If you'd like to get a general overview of my modules, say `s, list modules`
For a description of a module and what commands are available for it, say `s, help <module-name>`
For a detailed description of one of those commands say `s, help <command-name>` (where `command-name` is any of alternative names for that command)"""
)

def __init__(self):
super().__init__()
self.help = ModuleHelp.from_docstring(self.class_name, __doc__)
self.re_help = re.compile(r"help \w+", re.I)

def process_message(self, message: ServiceMessage) -> Response:
if not (text := self.is_at_me(message)):
return Response()
if text == "list modules":
return Response(
confidence=10,
text=self.list_modules(),
why=f"{message.author.display_name} asked me to list my modules",
)
if text == "help":
return Response(
confidence=10,
text=self.STAMPY_HELP_MSG,
why=f"{message.author.display_name} asked me for generic help",
)
if self.re_help.match(text):
return Response(confidence=10, callback=self.cb_help, args=[text, message])

return Response()

def list_modules(self) -> str:
msg_descrs = sorted(
(mod.help.listed_descr for mod in self.utils.modules_dict.values()),
key=str.casefold,
)
return "I have the following modules:\n" + "\n".join(msg_descrs)

async def cb_help(self, msg_text: str, message: ServiceMessage) -> Response:
help_content = msg_text[len("help ") :]
# iterate over modules, sorted in reverse in order to put longer module names first to prevent overly eager matching
for mod_name, mod in sorted(self.utils.modules_dict.items(), reverse=False):
if cmd_help := mod.help.get_command_help(msg_text=help_content):
return Response(
confidence=10,
text=cmd_help,
why=f'{message.author.display_name} asked me for help with "{help_content}"',
)
# module help
if mod_name.casefold() in help_content.casefold():
mod_help = mod.help.get_module_help(markdown=False)
why = f"{message.author.display_name} asked me for help with module `{mod_name}`"
if mod_help is None:
msg_text = f"No help for module `{mod_name}`"
why += " but help is not written for it"
else:
msg_text = mod_help
return Response(confidence=10, text=msg_text, why=why)

# nothing found
return Response(
confidence=10,
text=f'I couldn\'t find any help info related to "{help_content}". Could you rephrase that?',
why=f'{message.author.display_name} asked me for help with "{help_content}" but I found nothing.',
)

@property
def test_cases(self) -> list[IntegrationTest]:
module_help_tests = [
self.create_integration_test(
test_message=f"help {mod_name}",
expected_regex=rf"\*\*Module `{mod_name}`\*\*",
)
for mod_name, mod in self.utils.modules_dict.items()
if not mod.help.empty
]
helpless_module_tests = list(
islice(
(
self.create_integration_test(
test_message=f"help {mod_name}",
expected_regex=f"No help for module `{mod_name}`",
)
for mod_name, mod in self.utils.modules_dict.items()
if mod.help.empty
),
3,
)
)
return (
[
self.create_integration_test(
test_message="list modules",
expected_regex=r"I have the following modules:(\n- `\w+`[^\n]*)+",
),
self.create_integration_test(
test_message="help", expected_response=self.STAMPY_HELP_MSG
),
]
+ module_help_tests
+ helpless_module_tests
)
27 changes: 19 additions & 8 deletions modules/Random.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import re
import random
from modules.module import Module, Response
from utilities.serviceutils import ServiceMessage

from utilities.utilities import Utilities, randbool
from utilities.utilities import Utilities

utils = Utilities.get_instance()


class Random(Module):
def process_message(self, message):
def process_message(self, message: ServiceMessage) -> Response:
atme = self.is_at_me(message)
text = atme or message.clean_content
who = message.author.name
who = message.author.display_name

# dice rolling
if re.search("^roll [0-9]+d[0-9]+$", text):
Expand All @@ -37,25 +39,34 @@ def process_message(self, message):

if result:
return Response(
confidence=9, text=result, why=f"{who} asked me to roll {count} {sides}-sided dice"
confidence=9,
text=result,
why=f"{who} asked me to roll {count} {sides}-sided dice",
)

# "Stampy, choose coke or pepsi or both"
elif text.startswith("choose ") and " or " in text:
if text.startswith("choose ") and " or " in text:
# repetition guard
if atme and utils.message_repeated(message, text):
self.log.info(
self.class_name, msg="We don't want to lock people in due to phrasing"
self.class_name,
msg="We don't want to lock people in due to phrasing",
)
return Response()
cstring = text.partition(" ")[2].strip("?")
# options = [option.strip() for option in cstring.split(" or ")] # No oxford commas please
options = [option.strip() for option in re.split(" or |,", cstring) if option.strip()]
options = [
option.strip()
for option in re.split(" or |,", cstring)
if option.strip()
]
options_str = ", ".join(options)
return Response(
confidence=9,
text=random.choice(options),
why="%s asked me to choose between the options [%s]" % (who, ", ".join(options)),
why=f"{who} asked me to choose between the options [{options_str}]",
)
return Response()

def __str__(self):
return "Random"
Loading