diff --git a/README.md b/README.md index a5af75a1..82230ec9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api/coda.py b/api/coda.py index eaee9b03..a4eac3d7 100644 --- a/api/coda.py +++ b/api/coda.py @@ -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() @@ -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:" @@ -447,7 +447,7 @@ 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 @@ -455,7 +455,7 @@ async def get_response_text_and_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}"?' @@ -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: diff --git a/build_help.py b/build_help.py index 5cf23642..2494202b 100644 --- a/build_help.py +++ b/build_help.py @@ -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__": diff --git a/modules/Eliza.py b/modules/Eliza.py index 8c319d50..94223b71 100644 --- a/modules/Eliza.py +++ b/modules/Eliza.py @@ -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() diff --git a/modules/Factoids.py b/modules/Factoids.py index b002ef5e..fa7839d2 100644 --- a/modules/Factoids.py +++ b/modules/Factoids.py @@ -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 = "" @@ -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"', diff --git a/modules/HelpModule.py b/modules/HelpModule.py new file mode 100644 index 00000000..58d4d0b4 --- /dev/null +++ b/modules/HelpModule.py @@ -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 ` - returns description of a module and lists all of its commands +`s, help ` - 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 ` + For a detailed description of one of those commands say `s, help ` (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 + ) diff --git a/modules/Random.py b/modules/Random.py index bb9be61d..05092512 100644 --- a/modules/Random.py +++ b/modules/Random.py @@ -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): @@ -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" diff --git a/modules/Silly.py b/modules/Silly.py index 2f4c8d65..a46cfd5f 100644 --- a/modules/Silly.py +++ b/modules/Silly.py @@ -4,7 +4,6 @@ import datetime import string -from typing import Dict from modules.module import Module, Response, ServiceMessage from utilities.utilities import Utilities, randbool @@ -12,15 +11,10 @@ class Silly(Module): - def __init__(self): - super().__init__() - - 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 - print(atme) - print(text) + who = message.author.display_name if atme and utils.message_repeated(message, text): self.log.info( diff --git a/modules/StampyControls.py b/modules/StampyControls.py index 08839580..c09750ff 100644 --- a/modules/StampyControls.py +++ b/modules/StampyControls.py @@ -9,7 +9,7 @@ bot_control_channel_ids, member_role_id, Stampy_Path, - bot_reboot + bot_reboot, ) from modules.module import IntegrationTest, Module, Response from servicemodules.serviceConstants import Services @@ -19,7 +19,7 @@ get_memory_usage, get_running_user_info, get_question_id, - is_bot_dev + is_bot_dev, ) from utilities.serviceutils import ServiceMessage @@ -46,7 +46,9 @@ def is_at_module(self, message: ServiceMessage) -> Optional[str]: async def send_control_message(self, message: ServiceMessage, text: str) -> None: if self.utils.test_mode: question_id = get_question_id(message) - await message.channel.send(TEST_RESPONSE_PREFIX + str(question_id) + ": " + text) + await message.channel.send( + TEST_RESPONSE_PREFIX + str(question_id) + ": " + text + ) else: await message.channel.send(text) @@ -57,21 +59,24 @@ def process_message(self, message: ServiceMessage) -> Response: return Response( confidence=10, callback=routine, - why=f"{message.author.name} said '{routine_name}', which is a special command, so I ran the {routine_name} routine", - args=[message] + why=f"{message.author.display_name} said '{routine_name}', which is a special command, so I ran the {routine_name} routine", + args=[message], ) return Response() @staticmethod async def reboot(message: ServiceMessage) -> Response: - if hasattr(message.channel, "id") and message.channel.id in bot_control_channel_ids: + if ( + hasattr(message.channel, "id") + and message.channel.id in bot_control_channel_ids + ): asked_by_dev = is_bot_dev(message.author) if asked_by_dev: if bot_reboot and not os.path.exists(Stampy_Path): return Response( confidence=10, why=f"I couldn't find myself at this path: {Stampy_Path}", - text="I need to do some soul-searching." + text="I need to do some soul-searching.", ) await message.channel.send("Rebooting...") sys.stdout.flush() @@ -80,7 +85,9 @@ async def reboot(message: ServiceMessage) -> Response: # Alternative: self-managed reboot, without needing external loop. # BUG: When rebooting, Flask throws an error about port 2300 # being still in use. However the app seems to keep working. - os.execvp("bash", ["bash", "--login", "-c", f"python3 {Stampy_Path}"]) + os.execvp( + "bash", ["bash", "--login", "-c", f"python3 {Stampy_Path}"] + ) else: # expecting external infinite loop to make it a reboot. # return value of "42" can be used to distinguish from @@ -89,18 +96,20 @@ async def reboot(message: ServiceMessage) -> Response: sys.exit("Shutting down, expecting a reboot") return Response( confidence=10, - why="%s tried to kill me! They said 'reboot'" % message.author.name, + why=f"{message.author.display_name} tried to kill me! They said 'reboot'", text="You're not my supervisor!", ) return Response( confidence=10, - why=f"{message.author.name} tried to kill me! They said 'reboot'", + why=f"{message.author.display_name} tried to kill me! They said 'reboot'", text="This is not the place for violent murder of an agent.", ) async def add_member_role(self, message: ServiceMessage) -> Response: if message.service != Services.DISCORD: - return Response(confidence=10, text="This feature is only available on Discord") + return Response( + confidence=10, text="This feature is only available on Discord" + ) if not member_role_id: return Response(confidence=10, text="Variable member_role_id not defined") @@ -109,14 +118,14 @@ async def add_member_role(self, message: ServiceMessage) -> Response: if not member_role: return Response( confidence=10, - why=f"{message.author.name} asked to add member role", + why=f"{message.author.display_name} asked to add member role", text="this server doesn't have a member role yet", ) asked_by_mod = discord.utils.get(message.author.roles, name="mod") if not asked_by_mod: return Response( confidence=10, - why=f"{message.author.name} asked to add member role", + why=f"{message.author.display_name} asked to add member role", text=f"naughty <@{message.author.id}>, you are not a mod :face_with_raised_eyebrow:", ) @@ -124,12 +133,13 @@ async def add_member_role(self, message: ServiceMessage) -> Response: if not members: return Response( confidence=10, - why=f"{message.author.name} asked to add member role", + why=f"{message.author.display_name} asked to add member role", text="but everybody is a member already :shrug:", ) len_members = len(members) await self.send_control_message( - message, f"[adding member role to {len_members} users, this might take a moment...]" + message, + f"[adding member role to {len_members} users, this might take a moment...]", ) done = [] @@ -140,7 +150,8 @@ async def add_member_role(self, message: ServiceMessage) -> Response: i += 1 if i % 20 == 0: await self.send_control_message( - message, f'[... new members {i}/{len_members}: {", ".join(done)} ...]' + message, + f'[... new members {i}/{len_members}: {", ".join(done)} ...]', ) done = [] if done: @@ -150,18 +161,20 @@ async def add_member_role(self, message: ServiceMessage) -> Response: return Response( confidence=10, - why=f"{message.author.name} asked to add member role", + why=f"{message.author.display_name} asked to add member role", text="[... done adding member role]", ) - def create_stampy_stats_message(self)->str: + def create_stampy_stats_message(self) -> str: git_message = get_github_info() run_message = get_running_user_info() memory_message = get_memory_usage() runtime_message = self.utils.get_time_running() modules_message = self.utils.list_modules() # scores_message = self.utils.modules_dict["StampsModule"].get_user_scores() - return "\n\n".join([git_message, run_message, memory_message, runtime_message, modules_message]) + return "\n\n".join( + [git_message, run_message, memory_message, runtime_message, modules_message] + ) async def get_stampy_stats(self, message: ServiceMessage) -> Response: """ @@ -169,13 +182,17 @@ async def get_stampy_stats(self, message: ServiceMessage) -> Response: """ stats_message = self.create_stampy_stats_message() return Response( - confidence=10, why=f"because {message.author.name} asked for my stats", text=stats_message + confidence=10, + why=f"because {message.author.display_name} asked for my stats", + text=stats_message, ) @property def test_cases(self) -> list[IntegrationTest]: return [ - self.create_integration_test(test_message="reboot", expected_response=self.REBOOT_DENIED_MESSAGE), + self.create_integration_test( + test_message="reboot", expected_response=self.REBOOT_DENIED_MESSAGE + ), self.create_integration_test( test_message="stats", expected_response=self.create_stampy_stats_message(), diff --git a/modules/chatgpt.py b/modules/chatgpt.py index 353d4e36..3974ef36 100644 --- a/modules/chatgpt.py +++ b/modules/chatgpt.py @@ -1,6 +1,6 @@ -from typing import cast - from openai.openai_object import OpenAIObject +import re +from typing import cast, TYPE_CHECKING from api.openai import OpenAI from api.utilities.openai import OpenAIEngines @@ -20,7 +20,9 @@ from helicone import openai else: import openai -import re + +if TYPE_CHECKING: + from openai.openai_object import OpenAIObject openai.api_key = openai_api_key @@ -86,7 +88,7 @@ def generate_messages_list(self, channel) -> list[dict[str, str]]: messages = [] chatlog = "" for message in self.message_logs[channel][::-1]: - username = message.author.name + username = message.author.display_name text = message.clean_content if len(text) > self.log_message_max_chars: diff --git a/modules/module.py b/modules/module.py index f4dbdbf3..ee038225 100644 --- a/modules/module.py +++ b/modules/module.py @@ -9,6 +9,7 @@ from structlog import get_logger from config import TEST_MESSAGE_PREFIX +from utilities.help_utils import ModuleHelp from utilities.utilities import ( Utilities, is_stampy_mentioned, @@ -123,41 +124,29 @@ def __repr__(self) -> str: ) -@dataclass(frozen=True) -class ModuleHelp: - module_name: str - descr: Optional[str] - capabilities: dict[str, str] - docstring: Optional[str] - - @property - def descr_str(self) -> str: - if self.descr is None: - return f"`descr` for module `{self.module_name}` not available" - return self.descr - - class Module: """Informal Interface specification for modules These represent packets of functionality. For each message, we show it to each module and ask if it can process the message, then give it to the module that's most confident""" - def make_module_help( - self, descr: Optional[str] = None, capabilities: Optional[dict[str, str]] = None - ) -> ModuleHelp: - return ModuleHelp( - module_name=self.class_name, - descr=descr, - capabilities=capabilities or {}, - docstring=__doc__, - ) + # def make_module_help( + # self, + # descr: Optional[str] = None, + # capabilities: Optional[CapabilitiesDict] = None, + # ) -> ModuleHelp: + # return ModuleHelp( + # module_name=self.class_name, + # descr=descr, + # capabilities=capabilities or {}, + # docstring=__doc__, + # ) def __init__(self): self.utils = Utilities.get_instance() self.log = get_logger() self.re_replace = re.compile(r".*?({{.+?}})") - self.help = self.make_module_help() + self.help = ModuleHelp.from_docstring(self.class_name, __doc__) def process_message(self, message: ServiceMessage) -> Response: """Handle the message, return a string which is your response. diff --git a/modules/question_setter.py b/modules/question_setter.py index 2ab04e60..d9030eba 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -1,88 +1,47 @@ """ Changing status (in future perhaps also other attributes) of questions in Coda. - **Permissions:** - - All server members can contribute to AI Safety Questions and [ask for feedback](#review-request). - Only `@bot dev`s, `@editor`s, and `@reviewer`s can change question status by other commands ([1](#marking-questions-for-deletion-or-as-duplicates) [2](#setting-question-status)). - Only `@reviewers` can change status of questions to and from `Live on site` (including [accepting](#review-acceptance) [review requests](#review-request)). -### Review request - -On Rob Miles's Discord server, an `@editor` can ask other `@editor`s and `@reviewer`s to give them feedback or review their changes to AI Safety Info questions. You just put one or more links to appropriate GDocs and mention one of: `@reviewer`, `@feedback`, or `@feedback-sketch`. Stampy will spot this and update their statuses in the [coda table with answers](https://coda.io/d/AI-Safety-Info_dfau7sl2hmG/All-Answers_sudPS#_lul8a) appropriately. - -- `@reviewer` -> `In review` -- `@feedback` -> `In progress` -- `@feedback-sketch` -> `Bulletpoint sketch` - -![](images/help/QuestionSetter-review-request.png) - -Some remarks: - -- Optimally, review requesting and approval should be mostly confined to the `#editing` forum-channel. -- You don't need to call Stampy explicitly to make him update question status. All that matters is that you include one or more valid links to GDocs with AI Safety Info questions and an appropriate at-mention. - -### Review acceptance - -A `@reviewer` can **accept** a question by (1) responding to a [review request](#review-request) with a keyword (listed below) or (2) posting one or more valid links to GDocs with AI Safety Info questions with a keyword. Stampy then reacts by changing status to `Live on site`. +Review request, @reviewer, @feedback, @feedback-sketch +Request a review on an answer you wrote/edited +On Rob Miles's Discord server, an `@editor` can ask other `@editor`s and `@reviewer`s to give them feedback or review their changes to AI Safety Info questions. You just put one or more links to appropriate GDocs and mention one of: `@reviewer`, `@feedback`, or `@feedback-sketch`. Stampy will spot this and update their statuses in the coda table with answers appropriately. +`@reviewer ` - change status to `In review` +`@feedback ` - change status to `In progress` +`@feedback-sketch ` - change status to `Bulletpoint sketch` +Review acceptance, accepted, approved, lgtm +Accept a review, setting question status to `Live on Site` +A `@reviewer` can **accept** a question by (1) responding to a review request with a keyword (listed below) or (2) posting one or more valid links to GDocs with AI Safety Info questions with a keyword. Stampy then reacts by changing status to `Live on site`. The keywords are (case-insensitive): - - accepted - approved - lgtm - stands for "looks good to me" -![](images/help/QuestionSetter-review-acceptance.png) - -### Marking questions for deletion or as duplicates - -Use `s, ` (or `stampy, `) to change status of questions to `Marked for deletion` or `Duplicate` - -![](images/help/QuestionSetter-del-dup.png) - -### Setting question status - -Question status can be changed more flexibly, using the command: ` `, followed by appropriate GDoc links. - -Status name is case-insensitive and you can use status aliases. - -![](images/help/QuestionSetter-set-status.png) - -### Editing tags and alternate phrasings of questions - -Add a tag to a question (specified by title, GDocLink, or the last one) - -`s, ` (doesn't matter whether you put `` or `` first) - -![](images/help/QuestionSetter-add-tag-gdoc-link.png) - -If you don't specify the question, Stampy assumes you refer to the last one - -![](images/help/QuestionSetter-add-tag.png) - -Remove a tag from a question - -`s, ` - -![](images/help/QuestionSetter-remove-tag.png) - -Clear all tags on a question - -`s, clear tags ` - -![](images/help/QuestionSetter-clear-tags.png) - ---- - -Editing alternate phrasings works similarly to tags, except you can only add or remove alternative phrasings to/from one question at a time (because if two questions have the same alternative phrasing, something is fundamentally wrong). You still can clear alternative phrasings on multiple questions at a time. - -Alternate phrasings must be specified within double quotes, otherwise, they're not going to be parsed at all. - -![](images/help/QuestionSetter-add-altphr.png) - -![](images/help/QuestionSetter-clear-altphr.png) +Mark for deletion or as duplicate, del, dup, deletion, duplicate +Change status of questions to `Marked for deletion` or `Duplicate` +`s, del ` - change status to `Marked for deletion` +`s, dup ` - change status to `Duplicate` + +Set question status, Status +Change status of a question +`s, ` - change status of the last question +`s, ` +`s, question ` - change status of a question fuzzily matching that title + +Editing tags or alternate phrasings, Tags, Alternate phrasings, Altphr +Add a tag or an alternate phrasing to a question (specified by title, GDocLink, or the last one) +`s, ` - specified by gdoc-links or question title (doesn't matter whether you put `` or `` first) +`s, ` - if you don't specify the question, Stampy assumes you refer to the last one +`s, ` - removing tags +`s, clear tags ` - clear all tags on a question +`s, "" ` - you must put the alternate phrasing in double quotes and can do it only on one question at a time +`s "" ` - analogously +`s, clear altphr` - here, on last question """ from __future__ import annotations @@ -94,6 +53,7 @@ from config import ENVIRONMENT_TYPE, coda_api_token from modules.module import IntegrationTest, Module, Response from utilities.discordutils import DiscordChannel +from utilities.help_utils import ModuleHelp from utilities.serviceutils import ServiceMessage from utilities.utilities import ( has_permissions, @@ -130,12 +90,13 @@ def __init__(self) -> None: if not self.is_available(): exc_msg = f"Module {self.class_name} is not available." if coda_api_token is None: - exc_msg += f" CODA_API_TOKEN is not set in `.env`." + exc_msg += " CODA_API_TOKEN is not set in `.env`." if is_in_testing_mode(): exc_msg += " Stampy is in testing mode right now." raise Exception(exc_msg) super().__init__() + self.help = ModuleHelp.from_docstring(self.class_name, __doc__) self.coda_api = CodaAPI.get_instance() self.msg_id2gdoc_links: dict[str, list[str]] = {} @@ -307,7 +268,7 @@ async def cb_review_request( return Response( confidence=10, text=msg, - why=f"{message.author.name} did something useful and I wanted coda to reflect that.", + why=f"{message.author.display_name} did something useful and I wanted coda to reflect that.", ) ######################### @@ -322,7 +283,6 @@ def parse_question_approval(self, message: ServiceMessage) -> Optional[Response] text = message.clean_content if not any(s in text.lower() for s in ("approved", "accepted", "lgtm")): return - if gdoc_links := parse_gdoc_links(text): return Response( confidence=10, @@ -369,6 +329,9 @@ async def cb_question_approval( await self.find_gdoc_links_in_msg(message.channel, msg_ref_id) gdoc_links = self.msg_id2gdoc_links.get(msg_ref_id, []) + if not gdoc_links: + return Response() + questions = self.coda_api.get_questions_by_gdoc_links(gdoc_links) if not questions: @@ -466,7 +429,7 @@ async def cb_edit_tag_or_altphr( return Response( confidence=10, text=f"You don't have permissions required to edit {tag_or_altphr}s <@{message.author}>", - why=f"{message.author.name} does not have permissions edit {tag_or_altphr}s on questions", + why=f"{message.author.display_name} does not have permissions edit {tag_or_altphr}s on questions", ) # inserts for generating messages @@ -479,7 +442,7 @@ async def cb_edit_tag_or_altphr( Response( confidence=10, text=f"I found no questions conforming to the query\n{pformat_to_codeblock(dict([query]))}", - why=f"{message.author.name} asked me to {edit_action} {tag_or_altphr} `{val}` {to_from_on} some question(s) but I found nothing", + why=f"{message.author.display_name} asked me to {edit_action} {tag_or_altphr} `{val}` {to_from_on} some question(s) but I found nothing", ) # adding/removing one altphr per many questions is not allowed if ( @@ -490,7 +453,7 @@ async def cb_edit_tag_or_altphr( return Response( confidence=10, text=f"I don't think you want to {edit_action} the same alternate phrasing {to_from_on} {len(questions)} questions. Please, choose one.", - why=f"{message.author.name} asked me to more than one question at once which is not the way to go", + why=f"{message.author.display_name} asked me to more than one question at once which is not the way to go", ) if edit_action != "clear": @@ -555,7 +518,7 @@ async def cb_edit_tag_or_altphr( response_text += f" {tag_or_altphr} `{val}` {to_from_on} " response_text += f"{n_edited} questions" if n_edited > 1 else "one question" - why = f"{message.author.name} asked me to {edit_action} " + why = f"{message.author.display_name} asked me to {edit_action} " if edit_action == "clear": why += f"{tag_or_altphr}s" elif tag_or_altphr == "tag": @@ -621,14 +584,14 @@ async def cb_set_question_status( return Response( confidence=10, text=f"You don't have permissions to changing question status, <@{message.author}>", - why=f"{message.author.name} tried changing question status, but I don't trust them.", + why=f"{message.author.display_name} tried changing question status, but I don't trust them.", ) if status == "Live on site" and not is_from_reviewer(message): return Response( confidence=10, text=f"You're not a reviewer, <@{message.author}>. Only reviewers can change status of questions to `Live on site`", - why=f"{message.author.name} wanted to set status to `Live on site` but they're not a reviewer.", + why=f"{message.author.display_name} wanted to set status to `Live on site` but they're not a reviewer.", ) questions = await self.coda_api.query_for_questions(q_spec_query, message) @@ -692,7 +655,7 @@ async def cb_set_question_status( return Response( confidence=10, text=msg, - why=f"{message.author.name} asked me to change status to `{status}`.", + why=f"{message.author.display_name} asked me to change status to `{status}`.", ) def __str__(self): diff --git a/modules/questions.py b/modules/questions.py index 69ef452c..c1a02c2c 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -1,84 +1,25 @@ """ -Querying the question database. No special permissions required. - -### Counting questions - -Stampy can count questions in the database. You can narrow down the counting using a particular status or tag. Use commands like these: - -- `s, count questions` - counts all questions -- `s, count questions with status live on site` - counts only questions with status `Live on site` -- `s, count questions tagged decision theory` - counts only questions with the tag `Decision theory` -- `s, count questions with status live on site and tagged decision theory` - counts only questions that **both** have status `Live on site` **and** the tag `Decision theory` - -![](images/help/Questions-count-questions.png) - ---- - -Status name is case-insensitive: there is no difference between `Live on site`, `live on site`, or `LIVE ON SITE`. Similarly for tags. You can also use acronym aliases for status (but not for tags), e.g., `los` for `Live on site` or `bs` for `Bulletpoint sketch`. - -### Posting questions - -You can use Stampy to query the database of questions. Stampy will put links to questions that match your query into the channel. - -The general pattern for that command is: `s, `. - -You can query in three ways - -#### 1. Title - -Stampy returns first question matching that title - -`s, get ` - -![](/images/help/Questions-get-adversarial.png) - -#### 2. Filtering by status on tags - -Stampy returns the specified number of questions (max 5, default 1) matching (optional) status and tag - -`s, get 3 questions with status in progress and tagged definitions` (like [above](#counting-questions)) - -![](images/help/Questions-get-3-questions-status-tagged.png) - -If you say, `s, next question`, then Stampy will query all questions, and post the least recently asked one. - -![](images/help/Questions-next.png) - -#### 3. Last - -Stampy will post last question he interacted with. - -`s, post last question` / `s, post it` - -![](/images/help/Questions-get-last.png) - ---- - -#### Autoposting (Rob Miles' server only) - -On Rob Miles' Discord server, Stampy posts a random least recently asked question, if the last question was posted on somebody's request **and** more than 6 hours passed since then. Stampy posts either to the `#editing` channel or the `#general` - -### Getting question info - -`s, get info ` (with any filtering option mentioned so far, except `next`) can be used to get detailed information about the question as an entity in the database. - -![](images/help/Questions-get-info-babyagi.png) - -### Reloading questions - -If you're a bot dev, editor, or reviewerr, you can ask Stampy to refresh questions cache (sync it with coda) by the command. - -`s, ` - -E.g., `s, reload questions` or `s, fetch new q` - -Stampy also does it whenever he sees a review request containing a GDoc link, which does not appear in any of the questions in his cache. - -If you use it and for some reason Stampy's question cache seems still seems to be out of sync with coda, use hardreload. - -`s, hardreload questions`. - -The difference is that while the former updates the current cache, the latter overwrites it with a new one, which is more certain to work but probably less memory-safe. If it turns out that this function is not necessary, it will be deleted. +Querying question database + +How many questions, Count questions +Count questions, optionally queried by status and/or tag +`s, count questions [with status ] [tagged ]` + +Get question, Post question, Next question +Post links to one or more questions +`s, [num-of-questions] question(s) [with status ] [tagged ]` - filter by status and/or tags and/or specify maximum number of questions (up to 5) +`s, question` - post next question with status `Not started` +`s, question ` - post question fuzzily matching that title + +Question info +Get info about question, printed in a codeblock +`s, question ` - filter by title (fuzzy matching) +`s, ` - get info about last question +`s, ` - get tinfo about the question under that GDoc link + +Refresh questions, Reload questions +Refresh bot's questions cache so that it's in sync with coda. (Only for bot devs and editors/reviewers) +`s, questions` """ from __future__ import annotations @@ -99,6 +40,14 @@ from config import coda_api_token from servicemodules.discordConstants import general_channel_id from modules.module import Module, Response +from utilities.help_utils import ModuleHelp +from utilities.utilities import ( + has_permissions, + is_in_testing_mode, + pformat_to_codeblock, +) +from utilities.serviceutils import ServiceMessage + if coda_api_token is not None: from utilities.question_query_utils import ( @@ -108,12 +57,6 @@ QuestionFilterNT, QuestionQuery, ) -from utilities.utilities import ( - has_permissions, - is_in_testing_mode, - pformat_to_codeblock, -) -from utilities.serviceutils import ServiceMessage load_dotenv() @@ -130,12 +73,13 @@ def __init__(self) -> None: if not self.is_available(): exc_msg = f"Module {self.class_name} is not available." if coda_api_token is None: - exc_msg += f" CODA_API_TOKEN is not set in `.env`." + exc_msg += " CODA_API_TOKEN is not set in `.env`." if is_in_testing_mode(): exc_msg += " Stampy is in testing mode right now." raise Exception(exc_msg) super().__init__() + self.help = ModuleHelp.from_docstring(self.class_name, __doc__) self.coda_api = CodaAPI.get_instance() # Time when last question was posted @@ -186,11 +130,11 @@ def process_message(self, message: ServiceMessage) -> Response: return Response() if text == "hardreload questions": return Response( - confidence=10, callback=self.cb_hardreload_questions, args=[message] + confidence=9, callback=self.cb_hardreload_questions, args=[message] ) if self.re_refresh_questions.match(text): return Response( - confidence=10, callback=self.cb_refresh_questions, args=[message] + confidence=9, callback=self.cb_refresh_questions, args=[message] ) if response := self.parse_count_questions_command(text, message): return response @@ -207,26 +151,26 @@ def process_message(self, message: ServiceMessage) -> Response: async def cb_hardreload_questions(self, message: ServiceMessage) -> Response: if not has_permissions(message.author): return Response( - confidence=10, + confidence=9, text=f"You don't have permissions to request hard-reload, <@{message.author}>", - why=f"{message.author.name} asked me to hard-reload questions questions but they don't have permissions for that", + why=f"{message.author.display_name} asked me to hard-reload questions questions but they don't have permissions for that", ) await message.channel.send( f"Ok, hard-reloading questions cache\nBefore: {len(self.coda_api.questions_df)} questions" ) self.coda_api.reload_questions_cache() return Response( - confidence=10, + confidence=9, text=f"After: {len(self.coda_api.questions_df)} questions", - why=f"{message.author.name} asked me to hard-reload questions", + why=f"{message.author.display_name} asked me to hard-reload questions", ) async def cb_refresh_questions(self, message: ServiceMessage) -> Response: if not has_permissions(message.author): return Response( - confidence=10, + confidence=9, text=f"You don't have permissions, <@{message.author}>", - why=f"{message.author.name} wanted me to refresh questions questions but they don't have permissions for that", + why=f"{message.author.display_name} wanted me to refresh questions questions but they don't have permissions for that", ) await message.channel.send( f"Ok, refreshing questions cache\nBefore: {len(self.coda_api.questions_df)} questions" @@ -257,9 +201,9 @@ async def cb_refresh_questions(self, message: ServiceMessage) -> Response: + "\n\t".join(f'"{q["title"]}"' for q in deleted_questions[:10]) ) + "\n\t..." return Response( - confidence=10, + confidence=9, text=response_text, - why=f"{message.author.name} asked me to refresh questions cache", + why=f"{message.author.display_name} asked me to refresh questions cache", ) ################### @@ -283,7 +227,7 @@ def parse_count_questions_command( filter_data = parse_question_filter(text) return Response( - confidence=10, + confidence=9, callback=self.cb_count_questions, args=[filter_data, message], why="I was asked to count questions", @@ -314,9 +258,9 @@ async def cb_count_questions( response_text += status_and_tag_response_text return Response( - confidence=10, + confidence=9, text=response_text, - why=f"{message.author.name} asked me to count questions{status_and_tag_response_text}", + why=f"{message.author.display_name} asked me to count questions{status_and_tag_response_text}", ) ###################### @@ -332,7 +276,7 @@ def parse_post_questions_command( return request_data = parse_question_query(text) return Response( - confidence=10, + confidence=9, callback=self.cb_post_questions, args=[request_data, message], ) @@ -351,9 +295,9 @@ async def cb_post_questions( + f" yourself, <@{message.author}>?" ) return Response( - confidence=10, + confidence=9, text=response_text, - why=f"If {message.author.name} has these links, they can surely post these question themselves", + why=f"If {message.author.display_name} has these links, they can surely post these question themselves", ) # get questions (can be emptylist) @@ -388,7 +332,7 @@ async def cb_post_questions( self.coda_api.last_question_id = questions[0]["id"] return Response( - confidence=10, + confidence=9, text=response_text, why=why, ) @@ -449,7 +393,7 @@ def parse_get_question_info( spec_data = parse_question_spec_query(text, return_last_by_default=True) return Response( - confidence=10, + confidence=9, callback=self.cb_get_question_info, args=[spec_data, message], ) @@ -485,7 +429,7 @@ async def cb_get_question_info( self.coda_api.last_question_id = questions[0]["id"] return Response( - confidence=10, + confidence=9, text=response_text, why=why, ) diff --git a/modules/reply.py b/modules/reply.py index 69b55d68..fd4b6f32 100644 --- a/modules/reply.py +++ b/modules/reply.py @@ -17,14 +17,18 @@ def is_post_request(self, text): """Is this message asking us to post a reply?""" self.log.info(self.class_name, text=text) if text: - return text.lower().endswith("post this") or text.lower().endswith("send this") + return text.lower().endswith("post this") or text.lower().endswith( + "send this" + ) else: return False @staticmethod def is_allowed(message): """[Deprecated] Is the message author authorised to make stampy post replies?""" - posting_role = discord.utils.find(lambda r: r.name == "poaster", message.guild.roles) + posting_role = discord.utils.find( + lambda r: r.name == "poaster", message.guild.roles + ) return posting_role in message.author.roles @staticmethod @@ -61,7 +65,9 @@ def post_reply(self, text, question_id): with open("database/topost.json", "w") as post_file: json.dump(responses_to_post, post_file, indent="\t") - self.log.info(self.class_name, msg=("dummy, posting %s to %s" % (text, question_id))) + self.log.info( + self.class_name, msg=("dummy, posting %s to %s" % (text, question_id)) + ) def comment_posting_threshold(self): """Return the number of stamps a reply needs in order to be posted""" @@ -81,7 +87,8 @@ def process_message(self, message): return Response( confidence=9, text=self.POST_MESSAGE % self.comment_posting_threshold(), - why="%s asked me to post a reply to YouTube" % message.author.name, + why="%s asked me to post a reply to YouTube" + % message.author.display_name, ) return Response() @@ -109,9 +116,14 @@ async def post_message(self, message, approvers=None): question_url = reference_text.split("\n")[-1].strip("<> \n") if "youtube.com" in question_url: - match = re.match(r"YouTube user (.*?)( just)? asked (a|this) question", reference_text) + match = re.match( + r"YouTube user (.*?)( just)? asked (a|this) question", + reference_text, + ) if match: - question_user = match.group(1) # YouTube user (.*) asked this question + question_user = match.group( + 1 + ) # YouTube user (.*) asked this question else: question_user = "Unknown User" #### @@ -130,7 +142,6 @@ async def post_message(self, message, approvers=None): question_url = self.utils.latest_question_posted["url"] question_title = self.utils.latest_question_posted["question_title"] else: - return ( "I don't remember the URL of the last question I posted here," " so I've probably been restarted since that happened.\n" @@ -138,13 +149,17 @@ async def post_message(self, message, approvers=None): ) if question_title: - answer_title = f"""{message.author.display_name}'s Answer to {question_title}""" + answer_title = ( + f"""{message.author.display_name}'s Answer to {question_title}""" + ) else: answer_title = f"""{message.author.display_name}'s Answer""" reply_message = self.extract_reply(message.clean_content) if "youtube.com" in question_url: - reply_message += "\n -- _I am a bot. This reply was approved by %s_" % approver_string + reply_message += ( + "\n -- _I am a bot. This reply was approved by %s_" % approver_string + ) quoted_reply_message = "> " + reply_message.replace("\n", "\n> ") report += "Ok, posting this:\n %s\n\nas a response to this question: <%s>" % ( @@ -154,7 +169,12 @@ async def post_message(self, message, approvers=None): answer_time = datetime.now() self.utils.wiki.add_answer( - answer_title, message.author.display_name, approvers, answer_time, reply_message, question_title, + answer_title, + message.author.display_name, + approvers, + answer_time, + reply_message, + question_title, ) if "youtube.com" in question_url: @@ -179,28 +199,45 @@ async def evaluate_message_stamps(self, message): users = [user async for user in reaction.users()] for user in users: approvers.append(user) - stampvalue = self.utils.modules_dict["StampsModule"].get_user_stamps(user) + stampvalue = self.utils.modules_dict[ + "StampsModule" + ].get_user_stamps(user) total += stampvalue - self.log.info(self.class_name, from_user=user, user_id=user.id, stampvalue=stampvalue) + self.log.info( + self.class_name, + from_user=user, + user_id=user.id, + stampvalue=stampvalue, + ) return total, approvers def has_been_replied_to(self, message): reactions = message.reactions - self.log.info(self.class_name, status="Testing if question has already been replied to") + self.log.info( + self.class_name, status="Testing if question has already been replied to" + ) self.log.info(self.class_name, message_reactions=reactions) if reactions: for reaction in reactions: react_type = getattr(reaction.emoji, "name", reaction.emoji) self.log.info(self.class_name, reaction_type=react_type) if react_type in ["📨", ":incoming_envelope:"]: - self.log.info(self.class_name, msg="Message has envelope emoji, it's already replied to") + self.log.info( + self.class_name, + msg="Message has envelope emoji, it's already replied to", + ) return True elif react_type in ["🚫", ":no_entry_sign:"]: - self.log.info(self.class_name, msg="Message has no entry sign, it's vetoed") + self.log.info( + self.class_name, msg="Message has no entry sign, it's vetoed" + ) return True - self.log.info(self.class_name, msg="Message has no envelope emoji, it has not already replied to") + self.log.info( + self.class_name, + msg="Message has no envelope emoji, it has not already replied to", + ) return False async def process_raw_reaction_event(self, event): @@ -211,13 +248,16 @@ async def process_raw_reaction_event(self, event): if emoji in ["stamp", "goldstamp"]: self.log.info(self.class_name, guild=self.utils.GUILD) - guild = discord.utils.find(lambda g: g.id == event.guild_id, self.utils.client.guilds) - channel = discord.utils.find(lambda c: c.id == event.channel_id, guild.channels) + guild = discord.utils.find( + lambda g: g.id == event.guild_id, self.utils.client.guilds + ) + channel = discord.utils.find( + lambda c: c.id == event.channel_id, guild.channels + ) message = await channel.fetch_message(event.message_id) if self.is_at_me(DiscordMessage(message)) and self.is_post_request( self.is_at_me(DiscordMessage(message)) ): - if self.has_been_replied_to(message): return @@ -230,9 +270,12 @@ async def process_raw_reaction_event(self, event): await channel.send(report) else: - report = "This reply has %s stamp points. I will send it when it has %s" % ( - stamp_score, - self.comment_posting_threshold(), + report = ( + "This reply has %s stamp points. I will send it when it has %s" + % ( + stamp_score, + self.comment_posting_threshold(), + ) ) await channel.send(report) @@ -240,6 +283,7 @@ async def process_raw_reaction_event(self, event): def test_cases(self): return [ self.create_integration_test( - test_message="post this", expected_response=self.POST_MESSAGE % self.comment_posting_threshold() + test_message="post this", + expected_response=self.POST_MESSAGE % self.comment_posting_threshold(), ) ] diff --git a/modules/stampcollection.py b/modules/stampcollection.py index 02c7ea56..1debbd57 100644 --- a/modules/stampcollection.py +++ b/modules/stampcollection.py @@ -249,7 +249,7 @@ async def load_votes_from_history(self) -> None: user_id=user.id, message_id=message.id, reaction_type=emoji, - author_name=message.author.name, + author_name=message.author.display_name, message_author_id=message.author.id, ) stamplog.write(string + "\n") @@ -295,7 +295,7 @@ async def process_raw_reaction_event(self, event) -> None: emoji=emoji, user_id=from_id, reaction_message_author_id=to_id, - reaction_message_author_name=message.author.name, + reaction_message_author_name=message.author.display_name, ) stamps_before_update = self.get_user_stamps(to_id) @@ -332,7 +332,7 @@ def process_message(self, message): return Response( confidence=9, text=f"You're worth {authors_stamps:.2f} stamps to me", - why=f"{message.author.name} asked how many stamps they're worth", + why=f"{message.author.display_name} asked how many stamps they're worth", ) elif text == "reloadallstamps": diff --git a/modules/testModule.py b/modules/testModule.py index 3f5f7290..df8bbf4c 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -1,3 +1,27 @@ +""" +Test whether I work as expected +Ideally, every Stampy module should have a suite of integration tests written for it. A Stampy integration test consists of Stampy sending a particular test message to the channel and testing whether he (i.e., Stampy himself) will respond to it as expected. You can run all tests at once or only for a subset of modules. +**Important:** tests can be run only in the private dev channel channel `#stampy-dev-priv`. + +Test all modules, Test yourself +Test all Stampy modules that have tests written for them. +`s, test yourself` +`s, test modules` - If it's not specified **which particular modules** are to be tested, all modules will be tested. + +Test some modules, Test modules +Test a selected subset of Stampy modules. +`s, test modules ...` - Specify one or more modules. Tests will be run only for those. + +Test one module, Test module +Test exactly one Stampy module. +`s, test module ` + +Send a long message +Stampy will send an absurdly long message to the channel so that you can see if it's properly shortened and wrapped. +This is not included in any tests; only meant as a possibility to check that one functionality. +`s, send a long message` +""" + from asyncio import sleep import re from textwrap import dedent @@ -5,10 +29,16 @@ from jellyfish import jaro_winkler_similarity -from config import TEST_MESSAGE_PREFIX, TEST_RESPONSE_PREFIX, test_response_message, bot_private_channel_id +from config import ( + TEST_MESSAGE_PREFIX, + TEST_RESPONSE_PREFIX, + test_response_message, + bot_private_channel_id, +) from modules.module import IntegrationTest, Module, Response from servicemodules.serviceConstants import Services from utilities import get_question_id, is_test_response +from utilities.help_utils import ModuleHelp from utilities.serviceutils import ServiceMessage from utilities.utilities import is_bot_dev @@ -35,9 +65,22 @@ class TestModule(Module): def __init__(self): super().__init__() + self.help = ModuleHelp.from_docstring(self.class_name, __doc__) self.sent_test: list[IntegrationTest] = [] def process_message(self, message: ServiceMessage): + if message.clean_content == "s, send a long message": + if not is_bot_dev(message.author): + return Response( + confidence=10, + text=f"You're not a bot dev, {message.author.display_name}", + why=f"{message.author.display_name} doesn't have permissions to ask me to spam the channel with absurdly long messages", + ) + self.log.info(self.class_name, msg="horrifically long message sent") + return Response( + confidence=10, text=str(list(range(25000))), why="you told me to dude!" + ) + if not self.is_at_module(message): return Response() # If this is a message coming from an integration test, @@ -73,14 +116,14 @@ def process_message(self, message: ServiceMessage): return Response( confidence=10, text="Testing is only allowed in the private channel", - why=f"{message.author.name} wanted to test me outside of the private channel which is prohibited!", + why=f"{message.author.display_name} wanted to test me outside of the private channel which is prohibited!", ) if not is_bot_dev(message.author): return Response( confidence=10, - text=f"You are not a bot dev, {message.author.name}", - why=f"{message.author.name} wanted to test me but they are not a bot dev", + text=f"You are not a bot dev, {message.author.display_name}", + why=f"{message.author.display_name} wanted to test me but they are not a bot dev", ) # Otherwise, this is a request for Stampy to run integration tests @@ -94,7 +137,7 @@ def process_message(self, message: ServiceMessage): confidence=10, text=f'I don\'t have a module named "{parsed_module_name}"', why=( - f"{message.author.name} asked me to test module " + f"{message.author.display_name} asked me to test module " f'"{parsed_module_name}" but I don\'t have such a module' ), ) @@ -108,7 +151,7 @@ def process_message(self, message: ServiceMessage): return Response( confidence=10, text="Yeah but which module?", - why=f"{message.author.name} asked me to test a module but they didn't specify which one", + why=f"{message.author.display_name} asked me to test a module but they didn't specify which one", ) else: modules_dict = self.parse_module_dict(message) @@ -116,7 +159,7 @@ def process_message(self, message: ServiceMessage): return Response( confidence=10, text="I don't have these modules. Are you sure you wrote their names correctly?", - why=f"{message.author.name} asked me to test some modules but I couldn't recognize their names.", + why=f"{message.author.display_name} asked me to test some modules but I couldn't recognize their names.", ) return Response( @@ -135,7 +178,10 @@ def is_at_module(self, message: ServiceMessage) -> bool: and message.service not in self.SUPPORTED_SERVICES ): return False - return any(phrase in message.clean_content for phrase in self.TEST_PHRASES) + text = self.is_at_me(message) + if not text: + text = message.clean_content + return any(text.startswith(phrase) for phrase in self.TEST_PHRASES) def parse_module_dict(self, message: ServiceMessage) -> dict[str, Module]: """Extract module names from the message (containing "test modules" phrase) @@ -144,11 +190,10 @@ def parse_module_dict(self, message: ServiceMessage) -> dict[str, Module]: """ text = message.clean_content if re.search(r"test modules ([\w\s]+)", text, re.I): - module_name_candidates = re.findall(r"\w+", text.lower()) + module_names = self.utils.parse_module_names(text) modules_dict = { - module_name: module - for module_name, module in self.utils.modules_dict.items() - if module_name.lower() in module_name_candidates + module_name: self.utils.modules_dict[module_name] + for module_name in module_names } return modules_dict return self.utils.modules_dict diff --git a/modules/test_longmessage.py b/modules/test_longmessage.py deleted file mode 100644 index d069b023..00000000 --- a/modules/test_longmessage.py +++ /dev/null @@ -1,16 +0,0 @@ -from modules.module import Module, Response - - -class test_longmessage(Module): - def process_message(self, message): - if text := self.is_at_me(message): - if text.startswith("send a long message"): - self.log.info("test_longmessage", msg="horrifically long message sent") - return Response(confidence=100, text=str([x for x in range(0, 25000)]), why="you told me to dude!") - else: - return Response() - else: - return Response() - - def __str__(self): - return "test_longmessage" diff --git a/modules/why.py b/modules/why.py index 67592010..ca40d607 100644 --- a/modules/why.py +++ b/modules/why.py @@ -59,20 +59,30 @@ async def specific(self, message: DiscordMessage) -> Response: messages = self._get_known_messages() if m_id not in messages: return Response( - confidence=5, text=self.FORGOT, why="I either didn't say that, or I've restarted since then." + confidence=5, + text=self.FORGOT, + why="I either didn't say that, or I've restarted since then.", ) m = messages[m_id] why = m["why"] builder = f"In general, it was because {why}\n\nBut here is my traceback:\n\n" for step in m["traceback"]: builder += f"{step}\n" - return Response(confidence=10, text=builder, why="I was asked why I said something.") + return Response( + confidence=10, text=builder, why="I was asked why I said something." + ) async def general(self, message: DiscordMessage) -> Response: m_id = await self._get_message_about(message) messages = self._get_known_messages() if m_id not in messages: return Response( - confidence=5, text=self.FORGOT, why="I either didn't say that, or I've restarted since then." + confidence=5, + text=self.FORGOT, + why="I either didn't say that, or I've restarted since then.", ) - return Response(confidence=10, text=messages[m_id]["why"], why="I was asked why I said something.") + return Response( + confidence=10, + text=messages[m_id]["why"], + why="I was asked why I said something.", + ) diff --git a/servicemodules/discord.py b/servicemodules/discord.py index 1e36e7d4..2befbbfe 100644 --- a/servicemodules/discord.py +++ b/servicemodules/discord.py @@ -4,7 +4,7 @@ import sys from textwrap import wrap import threading -from typing import Iterable, Generator, Union +from typing import Iterable, Generator, Union, cast import unicodedata import discord @@ -31,6 +31,7 @@ limit_text, ) from utilities.discordutils import DiscordMessage +from utilities.serviceutils import ServiceChannel log = get_logger() @@ -88,9 +89,11 @@ async def on_ready() -> None: members = "\n - " + "\n - ".join([member.name for member in guild.members]) log.info(self.class_name, guild_members=members) - await self.utils.client.get_channel(int(bot_private_channel_id)).send( - f"I just (re)started {get_git_branch_info()}!" - ) + if bot_private_channel_id is not None: + await cast( + ServiceChannel, + self.utils.client.get_channel(int(bot_private_channel_id)), + ).send(f"I just (re)started {get_git_branch_info()}!") for error_msg in self.utils.initialization_error_messages: await self.utils.log_error(error_msg) @@ -124,7 +127,7 @@ async def on_message( self.class_name, message_id=message.id, message_channel_name=message.channel.name, - message_author_name=message.author.name, + message_author_name=message.author.display_name, message_author_discriminator=message.author.discriminator, message_author_id=message.author.id, message_channel_id=message.channel.id, diff --git a/servicemodules/flask.py b/servicemodules/flask.py index 321a32be..df2561fd 100644 --- a/servicemodules/flask.py +++ b/servicemodules/flask.py @@ -47,13 +47,17 @@ def process_event(self) -> FlaskResponse: """ if request.is_json: message = request.get_json() - message["content"] += " s" # This plus s should make it always trigger the is_at_me functions. + message[ + "content" + ] += " s" # This plus s should make it always trigger the is_at_me functions. else: content = ( request.form.get("content") + " s" ) # This plus s should make it always trigger the is_at_me functions. key = request.form.get("key") - modules = json.loads(request.form.get("modules", json.dumps(list(self.modules.keys())))) + modules = json.loads( + request.form.get("modules", json.dumps(list(self.modules.keys()))) + ) message = {"content": content, "key": key, "modules": modules} response = self.on_message(FlaskMessage(message)) log.debug(class_name, response=response, type=type(response)) @@ -62,8 +66,7 @@ def process_event(self) -> FlaskResponse: def process_list_modules(self) -> FlaskResponse: return FlaskResponse(json.dumps(list(self.modules.keys()))) - def on_message(self, message) -> FlaskResponse: - + def on_message(self, message: FlaskMessage) -> FlaskResponse: if is_test_message(message.content) and self.utils.test_mode: log.info(class_name, type="TEST MESSAGE", message_content=message.content) @@ -71,7 +74,7 @@ def on_message(self, message) -> FlaskResponse: class_name, message_id=message.id, message_channel_name=message.channel.name, - message_author_name=message.author.name, + message_author_name=message.author.display_name, message_author_id=message.author.id, message_channel_id=message.channel.id, message_content=message.content, @@ -122,7 +125,9 @@ def on_message(self, message) -> FlaskResponse: top_response.callback(*top_response.args, **top_response.kwargs) ) else: - new_response = top_response.callback(*top_response.args, **top_response.kwargs) + new_response = top_response.callback( + *top_response.args, **top_response.kwargs + ) new_response.module = top_response.module responses.append(new_response) @@ -137,17 +142,23 @@ def on_message(self, message) -> FlaskResponse: builder += chunk ret = FlaskResponse(builder, 200) else: - ret = FlaskResponse("I don't have anything to say about that.", 200) + ret = FlaskResponse( + "I don't have anything to say about that.", 200 + ) else: ret = FlaskResponse("I don't have anything to say about that.", 200) sys.stdout.flush() return ret # If we get here we've hit maximum_recursion_depth. - return FlaskResponse("[Stampy's ears start to smoke. There is a strong smell of recursion]", 200) + return FlaskResponse( + "[Stampy's ears start to smoke. There is a strong smell of recursion]", 200 + ) def run(self): app.add_url_rule("/", view_func=self.process_event, methods=["POST"]) - app.add_url_rule("/list_modules", view_func=self.process_list_modules, methods=["GET"]) + app.add_url_rule( + "/list_modules", view_func=self.process_list_modules, methods=["GET"] + ) app.run(host="0.0.0.0", port=2300, debug=False) def stop(self): diff --git a/servicemodules/slack.py b/servicemodules/slack.py index 6fee1c30..dd542341 100644 --- a/servicemodules/slack.py +++ b/servicemodules/slack.py @@ -2,12 +2,23 @@ import sys import inspect import threading -from utilities import Utilities, is_test_message, is_test_question, is_test_response, get_question_id +from utilities import ( + Utilities, + is_test_message, + is_test_question, + is_test_response, + get_question_id, +) from utilities.slackutils import SlackUtilities, SlackMessage from modules.module import Response from collections.abc import Iterable from datetime import datetime -from config import TEST_RESPONSE_PREFIX, maximum_recursion_depth, slack_app_token, slack_bot_token +from config import ( + TEST_RESPONSE_PREFIX, + maximum_recursion_depth, + slack_app_token, + slack_bot_token, +) from slack_sdk.socket_mode import SocketModeClient from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.socket_mode.request import SocketModeRequest @@ -32,7 +43,10 @@ def process_event(self, client: SocketModeClient, req: SocketModeRequest) -> Non response = SocketModeResponse(envelope_id=req.envelope_id) client.send_socket_mode_response(response) - if req.payload["event"]["type"] == "message" and req.payload["event"].get("subtype") is None: + if ( + req.payload["event"]["type"] == "message" + and req.payload["event"].get("subtype") is None + ): self.on_message(SlackMessage(req.payload["event"])) """ @@ -47,7 +61,9 @@ def on_message(self, message: SlackMessage): from_stampy = self.slackutils.stampy_is_author(message) if is_test_message(message.content) and self.utils.test_mode: - log.info(class_name, type="TEST MESSAGE", message_content=message.clean_content) + log.info( + class_name, type="TEST MESSAGE", message_content=message.clean_content + ) elif from_stampy: for module in self.modules: module.process_message_from_stampy(message) @@ -60,7 +76,7 @@ def on_message(self, message: SlackMessage): class_name, message_id=message.id, message_channel_name=message.channel.name, - message_author_name=message.author.name, + message_author_name=message.author.display_name, message_author_id=message.author.id, message_channel_id=message.channel.id, message_is_dm=message_is_dm, @@ -96,7 +112,9 @@ def on_message(self, message: SlackMessage): response_callback=response.callback, response_args=args_string, response_text=( - response.text if not isinstance(response.text, Generator) else "[Generator]" + response.text + if not isinstance(response.text, Generator) + else "[Generator]" ), response_reasons=response.why, ) @@ -110,7 +128,9 @@ def on_message(self, message: SlackMessage): top_response.callback(*top_response.args, **top_response.kwargs) ) else: - new_response = top_response.callback(*top_response.args, **top_response.kwargs) + new_response = top_response.callback( + *top_response.args, **top_response.kwargs + ) new_response.module = top_response.module responses.append(new_response) @@ -140,7 +160,9 @@ def on_message(self, message: SlackMessage): return # If we get here we've hit maximum_recursion_depth. asyncio.run( - message.channel.send("[Stampy's ears start to smoke. There is a strong smell of recursion]") + message.channel.send( + "[Stampy's ears start to smoke. There is a strong smell of recursion]" + ) ) def _start(self, event: threading.Event): @@ -170,5 +192,7 @@ def start(self, event: threading.Event) -> threading.Timer: if slack_app_token and slack_bot_token: t.start() else: - log.info(class_name, msg="Skipping Slack since our token's aren't configured!") + log.info( + class_name, msg="Skipping Slack since our token's aren't configured!" + ) return t diff --git a/utilities/help_utils.py b/utilities/help_utils.py new file mode 100644 index 00000000..cfc4819b --- /dev/null +++ b/utilities/help_utils.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Literal, Optional, overload + +Format = Literal["markdown", "discord"] + + +@dataclass(frozen=True) +class ModuleHelp: + """Help for a particular Stampy module, parsed from the docstring placed at the top of the module file. + + ### Docstring specification + + If the module has no file-level docstring, then no problem. However, if it does, it must follow this specification. + + The docstring is divided into segments, defined as blocks of text separated by double newlines. + The **main segment** contains an obligatory short module description (`descr`) and an optional, longer module description (`longdescr`). + The main segment is followed by one or more **command segments**, each describing one specific command or a set of related commands: + what they do and how to use them. A detailed specification of command segments can be found in the docstring of the `CommandHelp` class. + """ + + module_name: str + descr: Optional[str] + longdescr: Optional[str] + commands: list[CommandHelp] + + @classmethod + def from_docstring(cls, module_name: str, docstring: Optional[str]) -> ModuleHelp: + """Parse help from docstring""" + if docstring is None: + return cls(module_name, None, None, []) + main_segment, *cmd_segments = re.split(r"\n{2,}", docstring.strip()) + if "\n" in main_segment: + descr, longdescr = main_segment.split("\n", 1) + else: + descr = main_segment + longdescr = None + commands = [CommandHelp.from_docstring_segment(segm) for segm in cmd_segments] + return cls(module_name, descr, longdescr, commands) + + @property + def empty(self) -> bool: + return self.descr is None + + @property + def listed_descr(self) -> str: + """Bulletpoint: module name followed by short description (if it exists)""" + if self.descr: + return f"- `{self.module_name}`: {self.descr}" + return f"- `{self.module_name}`" + + def get_module_name_header(self, *, markdown: bool) -> str: + """Get formatted header with module name""" + if markdown: + return f"## {self.module_name}" + return f"**Module `{self.module_name}`**" + + def get_command_help(self, msg_text: str) -> Optional[str]: + """Search for help for command mentioned in `msg_text`. If found, return it. Otherwise, return `None`.""" + if self.empty: + return + # iterate over commands + for cmd in self.commands: + # if some name of the command matches msg_text + if command_help_msg := cmd.get_help(msg_text, markdown=False): + return ( + f"{self.get_module_name_header(markdown=False)}\n{command_help_msg}" + ) + + def get_module_help(self, *, markdown: bool) -> Optional[str]: + """Get help for the module formatted either for markdown file or for Discord message. + If help is empty, return `None`. + """ + if self.empty: + return + segments = [self.get_module_name_header(markdown=markdown), self.descr] + if self.longdescr is not None: + segments.append(self.longdescr) + segments = ["\n".join(segments).replace("\n", "\n\n" if markdown else "\n")] + if self.commands: + segments.extend( + cmd.get_help(msg_text=None, markdown=markdown) for cmd in self.commands + ) + return "\n\n".join(segments) + + +@dataclass(frozen=True) +class CommandHelp: + """Help for a Stampy command doing a particular thing. + Parsed from a segment of the docstring placed at the top of the module file. + + ### Segment structure + + - First line - `name` of the command, optionally alternative names (`alt_names`) following it, interspersed with commas + - Second line - short description (`descr`) of the command + - Next lines + - `Code-styled` lines are parsed as `examples` of commands (it's advised to follow them with descriptions after hyphens) + - Lines that are not `code-styled` are parsed and concatenated into longer description (`longdescr`) of the command + - Remarks + - There must be no blank lines between the above + - You technically can mix `examples` lines with `longdescr` lines but please don't. If you write both, either put all `examples` first or `longdescr` first. + + ### Example + + ```md + Do something, Do sth + Stampy does something + A longer description of + how Stampy does something + `s, do sth` - Stampy does sth + `s, do something because of ` - Stampy does something because of "x" + ``` + """ + + name: str + alt_names: list[str] + descr: str + longdescr: Optional[str] + examples: list[str] + + @classmethod + def from_docstring_segment(cls, segment: str) -> CommandHelp: + """Parse `CommandHelp` from a segment of the docstring describing one command""" + lines = segment.splitlines() + # TODO: improve + assert ( + len(lines) >= 3 + ), "Must have at least a name (1), a description (2), and an example (3)" + name_line, descr = lines[:2] + name, alt_names = cls.parse_name_line(name_line) + longdescr = "\n".join(l for l in lines[2:] if not l.startswith("`")) or None + examples = [l for l in lines[2:] if l.startswith("`")] + return cls( + name=name, + alt_names=alt_names, + descr=descr, + longdescr=longdescr, + examples=examples, + ) + + @staticmethod + def parse_name_line(line: str) -> tuple[str, list[str]]: + """Parse the first line of a segment, containing main name of the command and optional alternative names""" + names = re.split(r",\s*", line) + assert names + name, *alt_names = names + return name, alt_names + + @property + def all_names(self) -> list[str]: + """Main name and alternative names in one list""" + return [self.name, *self.alt_names] + + def get_fmt_name_lines( + self, *, markdown: bool, matched_name: Optional[str] = None + ) -> str: + """Get formatted name line, differently for markdown and not markdown. + If `matched_name` is passed, bold it. + + ### `markdown = False` + + ```md + ### + + (, , ...) + ``` + + ### `markdown = True` + + ` (, , ...)` + """ + out = self.name + if markdown: + out = f"### {out}" + if self.alt_names: + out += "\n\n(" + ", ".join(self.alt_names) + ")" + elif self.alt_names: + out += " (" + ", ".join(self.alt_names) + ")" + if matched_name: + assert matched_name in out, f"{matched_name = }; {out = }" + out = out.replace(matched_name, f"**{matched_name}**") + return out + + # fmt:off + @overload + def get_help(self, msg_text: str, markdown: bool) -> Optional[str]:... + @overload + def get_help(self, msg_text: None, markdown: bool) -> str:... + # fmt:on + def get_help(self, msg_text: Optional[str], markdown: bool) -> Optional[str]: + """Get help for this command, if one of its names appears in `msg_text`. Otherwise, return `None`.""" + if msg_text: + if not (matched_name := self._name_match(msg_text)): + return + name_lines = self.get_fmt_name_lines( + markdown=markdown, matched_name=matched_name + ) + else: + name_lines = self.get_fmt_name_lines(markdown=markdown) + lines = [name_lines, self.descr] + if self.longdescr: + lines.append(self.longdescr) + lines.extend(self.examples) + joiner = "\n\n" if markdown else "\n" + return joiner.join(lines) + + def _name_match(self, msg_text: str) -> Optional[str]: + """check if any of this command's names appears in `msg_text`""" + for name in self.all_names: + if re.search(rf"(? bool: self.lastMessages[chan] = this_text return False + def parse_module_names(self, text: str) -> list[str]: + module_name_candidates = re.findall(r"\w+", text.lower()) + return sorted( + module_name + for module_name in self.modules_dict + if module_name.lower() in module_name_candidates + ) + def get_github_info() -> str: repo = Repo(".")