Skip to content

Commit

Permalink
Merge pull request #300 from StampyAI/help-module
Browse files Browse the repository at this point in the history
  • Loading branch information
ProducerMatt authored Jun 23, 2023
2 parents 7413817 + 5a2513f commit f760816
Show file tree
Hide file tree
Showing 23 changed files with 778 additions and 386 deletions.
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
- `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

0 comments on commit f760816

Please sign in to comment.