From 44088461e1a7d30b461835a23ab6c58eddc75587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 10:11:33 +0200 Subject: [PATCH 01/28] list modules works --- modules/HelpModule.py | 32 ++++++++++++++++++++++++++++++++ modules/module.py | 6 ++++++ 2 files changed, 38 insertions(+) create mode 100644 modules/HelpModule.py diff --git a/modules/HelpModule.py b/modules/HelpModule.py new file mode 100644 index 00000000..bf67e72d --- /dev/null +++ b/modules/HelpModule.py @@ -0,0 +1,32 @@ +from modules.module import Module, Response +from utilities.serviceutils import ServiceMessage + + +class HelpModule(Module): + def __init__(self): + super().__init__() + self.help = self.make_module_help( + descr="Helps you interact with me", + capabilities={ + "list what modules I have + short descriptions": "" + }, + ) + + 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.name} asked me to list my modules", + ) + # if text.startswith("help"): + + return Response() + + def list_modules(self) -> str: + msg_descrs = sorted( + mod.help.descr_msg for mod in self.utils.modules_dict.values() + ) + return "I have the following modules:\n" + "\n".join(msg_descrs) diff --git a/modules/module.py b/modules/module.py index f4dbdbf3..d8408076 100644 --- a/modules/module.py +++ b/modules/module.py @@ -136,6 +136,12 @@ def descr_str(self) -> str: return f"`descr` for module `{self.module_name}` not available" return self.descr + @property + def descr_msg(self) -> str: + if self.descr is None: + return f"- `{self.module_name}`" + return f"- `{self.module_name}`: {self.descr}" + class Module: """Informal Interface specification for modules From 592bbec6c5c6a84e66fc6b365c1c967253392dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 10:26:16 +0200 Subject: [PATCH 02/28] s, help commands added --- modules/HelpModule.py | 30 ++++++++++++++++++++++++++++-- modules/module.py | 5 +++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index bf67e72d..fe90ae84 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -3,6 +3,8 @@ class HelpModule(Module): + DEFAULT_HELP_RESPONSE = "#TODO: DEFAULT HELP RESPONSE" + def __init__(self): super().__init__() self.help = self.make_module_help( @@ -21,12 +23,36 @@ def process_message(self, message: ServiceMessage) -> Response: text=self.list_modules(), why=f"{message.author.name} asked me to list my modules", ) - # if text.startswith("help"): + if text == "help": + return Response( + confidence=10, + text=self.DEFAULT_HELP_RESPONSE, + why=f"{message.author.name} asked me for generic help", + ) + if text.startswith("help "): + return Response(confidence=8, callback=self.cb_help, args=[text, message]) return Response() def list_modules(self) -> str: msg_descrs = sorted( - mod.help.descr_msg for mod in self.utils.modules_dict.values() + (mod.help.descr_msg 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, text: str, message: ServiceMessage) -> Response: + help_content = text[len("help ") :] + for mod in self.utils.modules_dict.values(): + if mod_help := mod.help.get_help(text): + msg = f"`{mod.class_name}`: {mod_help}" + return Response( + confidence=10, + text=msg, + why=f'{message.author.name} asked me for help with "{help_content}"', + ) + 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.name} asked me for help with "{help_content}" but I found nothing.', + ) diff --git a/modules/module.py b/modules/module.py index d8408076..460a2ce6 100644 --- a/modules/module.py +++ b/modules/module.py @@ -142,6 +142,11 @@ def descr_msg(self) -> str: return f"- `{self.module_name}`" return f"- `{self.module_name}`: {self.descr}" + def get_help(self, msg_text: str) -> Optional[str]: + for k, v in self.capabilities.items(): + if k in msg_text: + return f"{k}:\n{v}" + class Module: """Informal Interface specification for modules From 4bcb554ea20635c9be18f314c028c0533209df40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 11:00:37 +0200 Subject: [PATCH 03/28] fixed message_id problems --- modules/chatgpt.py | 8 +++++--- modules/why.py | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/modules/chatgpt.py b/modules/chatgpt.py index 353d4e36..5f78d6f9 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 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.", + ) From 9609ce05f8b32f64bdf81769ad527e457b072a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 11:01:09 +0200 Subject: [PATCH 04/28] changed help capabilities dict type to dict[tuple[str, ...], tuples[str, str]] --- modules/HelpModule.py | 5 ++++- modules/module.py | 23 ++++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index fe90ae84..0a268f17 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -10,7 +10,10 @@ def __init__(self): self.help = self.make_module_help( descr="Helps you interact with me", capabilities={ - "list what modules I have + short descriptions": "" + ("list modules",): ( + "list what modules I have + short descriptions", + "``", + ) }, ) diff --git a/modules/module.py b/modules/module.py index 460a2ce6..7e8eb419 100644 --- a/modules/module.py +++ b/modules/module.py @@ -123,11 +123,16 @@ def __repr__(self) -> str: ) +CommandAliases = tuple[str, ...] +CommandDescr = CommandExample = str +CapabilitiesDict = dict[CommandAliases, tuple[CommandDescr, CommandExample]] + + @dataclass(frozen=True) class ModuleHelp: module_name: str descr: Optional[str] - capabilities: dict[str, str] + capabilities: CapabilitiesDict docstring: Optional[str] @property @@ -143,9 +148,15 @@ def descr_msg(self) -> str: return f"- `{self.module_name}`: {self.descr}" def get_help(self, msg_text: str) -> Optional[str]: - for k, v in self.capabilities.items(): - if k in msg_text: - return f"{k}:\n{v}" + for cmd_aliases, (cmd_descr, cmd_example) in self.capabilities.items(): + for alias in cmd_aliases: + if alias in msg_text: + msg_cmd_aliases = ( + "(" + + "|".join(a if a != alias else f"**{a}**" for a in cmd_aliases) + + ")" + ) + return f"{msg_cmd_aliases}: {cmd_descr}\n{cmd_example}" class Module: @@ -155,7 +166,9 @@ class Module: 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 + self, + descr: Optional[str] = None, + capabilities: Optional[CapabilitiesDict] = None, ) -> ModuleHelp: return ModuleHelp( module_name=self.class_name, From 6671e3c668c04d9a8ad3da88ab2e67e1c2052d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 12:23:53 +0200 Subject: [PATCH 05/28] added help for all command types in Questions --- modules/questions.py | 68 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index 69ef452c..bbfa6263 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -85,6 +85,7 @@ from datetime import datetime, timedelta import random import re +from textwrap import dedent from typing import cast, Optional from discord import Thread @@ -100,6 +101,7 @@ from servicemodules.discordConstants import general_channel_id from modules.module import Module, Response + if coda_api_token is not None: from utilities.question_query_utils import ( parse_question_filter, @@ -130,7 +132,7 @@ 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) @@ -181,16 +183,54 @@ async def on_socket_event_type(event_type) -> None: ) and not self.last_question_autoposted: await self.post_random_oldest_question(event_type) + self.help = self.make_module_help( + descr="Querying question database", + capabilities={ + ("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", + dedent( + """\ + `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", + dedent( + """\ + `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`", + ), + }, + ) + def process_message(self, message: ServiceMessage) -> Response: if not (text := self.is_at_me(message)): 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,7 +247,7 @@ 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", ) @@ -216,7 +256,7 @@ async def cb_hardreload_questions(self, message: ServiceMessage) -> Response: ) 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", ) @@ -224,7 +264,7 @@ async def cb_hardreload_questions(self, message: ServiceMessage) -> Response: 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", ) @@ -257,7 +297,7 @@ 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", ) @@ -283,7 +323,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,7 +354,7 @@ 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}", ) @@ -332,7 +372,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,7 +391,7 @@ 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", ) @@ -388,7 +428,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 +489,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 +525,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, ) From d512267369ec24a036f80d4e29228d8579da2827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 12:24:36 +0200 Subject: [PATCH 06/28] moved parsing module names from message text to utilities, help with docstring - WIP --- modules/HelpModule.py | 19 +++++++++++++++---- modules/module.py | 2 +- modules/testModule.py | 7 +++---- utilities/utilities.py | 8 ++++++++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index 0a268f17..e409b0a5 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -1,3 +1,4 @@ +import re from modules.module import Module, Response from utilities.serviceutils import ServiceMessage @@ -32,8 +33,10 @@ def process_message(self, message: ServiceMessage) -> Response: text=self.DEFAULT_HELP_RESPONSE, why=f"{message.author.name} asked me for generic help", ) - if text.startswith("help "): - return Response(confidence=8, callback=self.cb_help, args=[text, message]) + if re.match(r"help \w+", text): + return Response(confidence=10, callback=self.cb_help, args=[text, message]) + if re.match(r"docs \w+", text): + return Response(confidence=10, callback=self.cb_docs, args=[text, message]) return Response() @@ -48,10 +51,9 @@ async def cb_help(self, text: str, message: ServiceMessage) -> Response: help_content = text[len("help ") :] for mod in self.utils.modules_dict.values(): if mod_help := mod.help.get_help(text): - msg = f"`{mod.class_name}`: {mod_help}" return Response( confidence=10, - text=msg, + text=mod_help, why=f'{message.author.name} asked me for help with "{help_content}"', ) return Response( @@ -59,3 +61,12 @@ async def cb_help(self, text: str, message: ServiceMessage) -> Response: text=f'I couldn\'t find any help info related to "{help_content}". Could you rephrase that?', why=f'{message.author.name} asked me for help with "{help_content}" but I found nothing.', ) + + async def cb_docs(self, text: str, message: ServiceMessage) -> Response: + module_names = self.utils.parse_module_names(text) + if not module_names: + return Response( + confidence=10, + text="I don't have such a module", + why=f"{message.author.name} asked me for ", + ) diff --git a/modules/module.py b/modules/module.py index 7e8eb419..92b2f514 100644 --- a/modules/module.py +++ b/modules/module.py @@ -156,7 +156,7 @@ def get_help(self, msg_text: str) -> Optional[str]: + "|".join(a if a != alias else f"**{a}**" for a in cmd_aliases) + ")" ) - return f"{msg_cmd_aliases}: {cmd_descr}\n{cmd_example}" + return f"Module `{self.module_name}`\n{msg_cmd_aliases}\n{cmd_descr}\n{cmd_example}" class Module: diff --git a/modules/testModule.py b/modules/testModule.py index 3f5f7290..a61daf72 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -144,11 +144,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/utilities/utilities.py b/utilities/utilities.py index 0c065494..aa6e3175 100644 --- a/utilities/utilities.py +++ b/utilities/utilities.py @@ -355,6 +355,14 @@ def message_repeated(self, message: ServiceMessage, this_text: str) -> 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(".") From 765f024cd9ab768745ab1643e444b07d6ddd4e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 19:09:36 +0200 Subject: [PATCH 07/28] moved ModuleHelp to utilities.help_utils.py and adjusted Questions module; removed old docstrings from Questions and QuestionSetter --- modules/HelpModule.py | 51 +++++++------ modules/module.py | 61 ++++------------ modules/question_setter.py | 86 ---------------------- modules/questions.py | 142 ++++++------------------------------- utilities/help_utils.py | 127 +++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 275 deletions(-) create mode 100644 utilities/help_utils.py diff --git a/modules/HelpModule.py b/modules/HelpModule.py index e409b0a5..005662ce 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -1,5 +1,15 @@ +""" +Helps you interact with me + +list modules +list what modules I have + short descriptions +`s, list modules` +""" + import re + from modules.module import Module, Response +from utilities.help_utils import ModuleHelp from utilities.serviceutils import ServiceMessage @@ -8,15 +18,8 @@ class HelpModule(Module): def __init__(self): super().__init__() - self.help = self.make_module_help( - descr="Helps you interact with me", - capabilities={ - ("list modules",): ( - "list what modules I have + short descriptions", - "``", - ) - }, - ) + 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)): @@ -33,10 +36,8 @@ def process_message(self, message: ServiceMessage) -> Response: text=self.DEFAULT_HELP_RESPONSE, why=f"{message.author.name} asked me for generic help", ) - if re.match(r"help \w+", text): + if self.re_help.match(text): return Response(confidence=10, callback=self.cb_help, args=[text, message]) - if re.match(r"docs \w+", text): - return Response(confidence=10, callback=self.cb_docs, args=[text, message]) return Response() @@ -49,24 +50,30 @@ def list_modules(self) -> str: async def cb_help(self, text: str, message: ServiceMessage) -> Response: help_content = text[len("help ") :] + + # iterate over modules for mod in self.utils.modules_dict.values(): - if mod_help := mod.help.get_help(text): + # command help + # TODO: rename attr + if mod_help := mod.help.get_help_for_command(msg_text=help_content): return Response( confidence=10, text=mod_help, why=f'{message.author.name} asked me for help with "{help_content}"', ) + # module help + if mod.class_name.casefold() in help_content.casefold(): + # TODO: help is empty + msg_text = mod.help.get_help_for_module() + return Response( + confidence=10, + text=msg_text, + why=f"{message.author.name} asked me for help with module `{mod.class_name}`", + ) + + # 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.name} asked me for help with "{help_content}" but I found nothing.', ) - - async def cb_docs(self, text: str, message: ServiceMessage) -> Response: - module_names = self.utils.parse_module_names(text) - if not module_names: - return Response( - confidence=10, - text="I don't have such a module", - why=f"{message.author.name} asked me for ", - ) diff --git a/modules/module.py b/modules/module.py index 92b2f514..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,65 +124,29 @@ def __repr__(self) -> str: ) -CommandAliases = tuple[str, ...] -CommandDescr = CommandExample = str -CapabilitiesDict = dict[CommandAliases, tuple[CommandDescr, CommandExample]] - - -@dataclass(frozen=True) -class ModuleHelp: - module_name: str - descr: Optional[str] - capabilities: CapabilitiesDict - 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 - - @property - def descr_msg(self) -> str: - if self.descr is None: - return f"- `{self.module_name}`" - return f"- `{self.module_name}`: {self.descr}" - - def get_help(self, msg_text: str) -> Optional[str]: - for cmd_aliases, (cmd_descr, cmd_example) in self.capabilities.items(): - for alias in cmd_aliases: - if alias in msg_text: - msg_cmd_aliases = ( - "(" - + "|".join(a if a != alias else f"**{a}**" for a in cmd_aliases) - + ")" - ) - return f"Module `{self.module_name}`\n{msg_cmd_aliases}\n{cmd_descr}\n{cmd_example}" - - 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[CapabilitiesDict] = 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..33535cf6 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -1,89 +1,3 @@ -""" -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`. - -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) - -""" from __future__ import annotations import re diff --git a/modules/questions.py b/modules/questions.py index bbfa6263..7c921cdd 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -1,91 +1,31 @@ """ -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 from datetime import datetime, timedelta import random import re -from textwrap import dedent from typing import cast, Optional from discord import Thread @@ -100,6 +40,7 @@ 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 if coda_api_token is not None: @@ -138,6 +79,7 @@ def __init__(self) -> None: 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 @@ -183,44 +125,6 @@ async def on_socket_event_type(event_type) -> None: ) and not self.last_question_autoposted: await self.post_random_oldest_question(event_type) - self.help = self.make_module_help( - descr="Querying question database", - capabilities={ - ("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", - dedent( - """\ - `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", - dedent( - """\ - `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`", - ), - }, - ) - def process_message(self, message: ServiceMessage) -> Response: if not (text := self.is_at_me(message)): return Response() diff --git a/utilities/help_utils.py b/utilities/help_utils.py new file mode 100644 index 00000000..cb18351d --- /dev/null +++ b/utilities/help_utils.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Optional + +CommandAliases = tuple[str, ...] +CommandDescr = CommandExample = str +CapabilitiesDict = dict[CommandAliases, tuple[CommandDescr, CommandExample]] + + +@dataclass(frozen=True) +class CommandHelp: + name: str + alt_names: list[str] + descr: str + longdescr: Optional[str] + cases: list[str] + img_paths: list[str] # TODO + + @staticmethod + def parse_name_line(line: str) -> tuple[str, list[str]]: + if alt_name_match := re.search(r"(?<=\().+(?=\))", line): + name = line[: alt_name_match.span()[0] - 2].strip() + alt_names = [an.strip() for an in alt_name_match.group().split(",")] + else: + name = line.strip() + alt_names = [] + return name, alt_names + + @classmethod + def from_docstring_segment(cls, segment: str) -> CommandHelp: + 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 = lines[2] if not lines[2].startswith("`") else None + cases = [l for l in lines[2:] if l.startswith("`")] + return cls( + name=name, + alt_names=alt_names, + descr=descr, + longdescr=longdescr, + cases=cases, + img_paths=[], + ) + + @property + def all_names(self) -> list[str]: + return [self.name, *self.alt_names] + + @property + def names_fmt(self) -> str: + names_fmt = self.name + if self.alt_names: + names_fmt += " (" + "|".join(self.alt_names) + ")" + return names_fmt + + def name_match(self, msg_text: str) -> Optional[str]: + for name in self.all_names: + if name.casefold() in msg_text.casefold(): + return name + + def get_names_fmt_highlighted(self, name: str) -> Optional[str]: + if name in self.names_fmt: + return self.names_fmt.replace(name, f"**{name}**") + + @property + def help_msg(self) -> str: + msg = f"{self.names_fmt}\n{self.descr}\n" + if self.longdescr: + msg += f"{self.longdescr}\n" + msg += "\n".join(self.cases) + return msg + + +@dataclass(frozen=True) +class ModuleHelp: + module_name: str + descr: Optional[str] + longdescr: Optional[str] + commands: list[CommandHelp] + + @classmethod + def from_docstring(cls, module_name: str, docstring: Optional[str]) -> ModuleHelp: + if docstring is None: + return cls(module_name=module_name, descr=None, longdescr=None, commands=[]) + descr_segment, *command_segments = re.split(r"\n{2,}", docstring.strip()) + if "\n" in descr_segment: + descr, longdescr = descr_segment.split("\n", 1) + else: + descr = descr_segment + longdescr = None + cmds = [ + CommandHelp.from_docstring_segment(segment) for segment in command_segments + ] + return cls( + module_name=module_name, descr=descr, longdescr=longdescr, commands=cmds + ) + + @property + def descr_msg(self) -> str: + if self.descr is None: + return f"- `{self.module_name}`" + return f"- `{self.module_name}`: {self.descr}" + + @property + def module_name_header(self) -> str: + return f"Module `{self.module_name}`" + + def get_help_for_module(self) -> str: + msg = f"{self.module_name_header}\n{self.descr}\n" + if self.longdescr is not None: + msg += f"{self.longdescr}\n" + msg += "\n" + "\n\n".join(cmd.help_msg for cmd in self.commands) + return msg + + def get_help_for_command(self, msg_text: str) -> Optional[str]: + # iterate over commands + for cmd in self.commands: + # if some name of the command matches msg_text + if cmd_name := cmd.name_match(msg_text): + command_help_msg = cmd.help_msg.replace(cmd_name, f"**{cmd_name}**", 1) + return f"{self.module_name_header}\n{command_help_msg}" From bee23bde2d7e1cceca885ec0837d9fb4dd32d993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 19:20:21 +0200 Subject: [PATCH 08/28] cleaned up help_utils methods and added help command to HelpModule --- modules/HelpModule.py | 5 +++++ utilities/help_utils.py | 22 +++++++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index 005662ce..5be24759 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -4,6 +4,11 @@ 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 + """ import re diff --git a/utilities/help_utils.py b/utilities/help_utils.py index cb18351d..349f690b 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -54,20 +54,19 @@ def all_names(self) -> list[str]: @property def names_fmt(self) -> str: + """Formatted names: ` (, , ...)`""" names_fmt = self.name if self.alt_names: names_fmt += " (" + "|".join(self.alt_names) + ")" return names_fmt def name_match(self, msg_text: str) -> Optional[str]: + """check if any of this command's names appears in `msg_text`""" + msg_text = msg_text.casefold() for name in self.all_names: - if name.casefold() in msg_text.casefold(): + if name.casefold() in msg_text: return name - def get_names_fmt_highlighted(self, name: str) -> Optional[str]: - if name in self.names_fmt: - return self.names_fmt.replace(name, f"**{name}**") - @property def help_msg(self) -> str: msg = f"{self.names_fmt}\n{self.descr}\n" @@ -112,10 +111,15 @@ def module_name_header(self) -> str: return f"Module `{self.module_name}`" def get_help_for_module(self) -> str: - msg = f"{self.module_name_header}\n{self.descr}\n" - if self.longdescr is not None: - msg += f"{self.longdescr}\n" - msg += "\n" + "\n\n".join(cmd.help_msg for cmd in self.commands) + msg = f"{self.module_name_header}\n" + if self.descr is not None: + msg += f"{self.descr}\n" + if self.longdescr is not None: + msg += f"{self.longdescr}\n" + else: + msg += "No module description available\n" + if self.commands: + msg += "\n" + "\n\n".join(cmd.help_msg for cmd in self.commands) return msg def get_help_for_command(self, msg_text: str) -> Optional[str]: From 14f7d87a6f7c843c5bea4a32a960ee9f87fc7e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 19:33:34 +0200 Subject: [PATCH 09/28] fixed parsing command name and finished HelpModule docstring --- modules/HelpModule.py | 15 +++++++-------- utilities/help_utils.py | 3 +-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index 5be24759..b954438b 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -7,8 +7,8 @@ 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 - +`s, help ` - returns description of a module and lists all of its commands +`s, help ` - returns description of a command """ import re @@ -33,13 +33,13 @@ def process_message(self, message: ServiceMessage) -> Response: return Response( confidence=10, text=self.list_modules(), - why=f"{message.author.name} asked me to list my modules", + why=f"{message.author.display_name} asked me to list my modules", ) if text == "help": return Response( confidence=10, text=self.DEFAULT_HELP_RESPONSE, - why=f"{message.author.name} asked me for generic help", + 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]) @@ -55,7 +55,6 @@ def list_modules(self) -> str: async def cb_help(self, text: str, message: ServiceMessage) -> Response: help_content = text[len("help ") :] - # iterate over modules for mod in self.utils.modules_dict.values(): # command help @@ -64,7 +63,7 @@ async def cb_help(self, text: str, message: ServiceMessage) -> Response: return Response( confidence=10, text=mod_help, - why=f'{message.author.name} asked me for help with "{help_content}"', + why=f'{message.author.display_name} asked me for help with "{help_content}"', ) # module help if mod.class_name.casefold() in help_content.casefold(): @@ -73,12 +72,12 @@ async def cb_help(self, text: str, message: ServiceMessage) -> Response: return Response( confidence=10, text=msg_text, - why=f"{message.author.name} asked me for help with module `{mod.class_name}`", + why=f"{message.author.display_name} asked me for help with module `{mod.class_name}`", ) # 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.name} asked me for help with "{help_content}" but I found nothing.', + why=f'{message.author.display_name} asked me for help with "{help_content}" but I found nothing.', ) diff --git a/utilities/help_utils.py b/utilities/help_utils.py index 349f690b..ce4e47ff 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -62,9 +62,8 @@ def names_fmt(self) -> str: def name_match(self, msg_text: str) -> Optional[str]: """check if any of this command's names appears in `msg_text`""" - msg_text = msg_text.casefold() for name in self.all_names: - if name.casefold() in msg_text: + if re.search(rf"(? Date: Sat, 17 Jun 2023 19:51:54 +0200 Subject: [PATCH 10/28] moved test_longmessage to TestModule --- modules/testModule.py | 12 ++++++++++++ modules/test_longmessage.py | 16 ---------------- 2 files changed, 12 insertions(+), 16 deletions(-) delete mode 100644 modules/test_longmessage.py diff --git a/modules/testModule.py b/modules/testModule.py index a61daf72..2667ea63 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -38,6 +38,18 @@ def __init__(self): 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, 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" From d12f3696e53ac240e2149e3079a70029e8ea1294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 19:58:48 +0200 Subject: [PATCH 11/28] changed message.author.name to message.author.display_name except where it seems to be saved to some cache/db --- api/coda.py | 10 ++-- modules/Eliza.py | 9 ++-- modules/Random.py | 19 ++++++-- modules/StampyControls.py | 59 ++++++++++++++--------- modules/chatgpt.py | 2 +- modules/question_setter.py | 16 +++---- modules/questions.py | 12 ++--- modules/reply.py | 96 +++++++++++++++++++++++++++++--------- modules/stampcollection.py | 6 +-- modules/testModule.py | 15 ++++-- servicemodules/discord.py | 2 +- servicemodules/flask.py | 29 ++++++++---- servicemodules/slack.py | 42 +++++++++++++---- 13 files changed, 218 insertions(+), 99 deletions(-) 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/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/Random.py b/modules/Random.py index bb9be61d..bfe90379 100644 --- a/modules/Random.py +++ b/modules/Random.py @@ -2,10 +2,11 @@ import random from modules.module import Module, Response -from utilities.utilities import Utilities, randbool +from utilities.utilities import Utilities utils = Utilities.get_instance() + class Random(Module): def process_message(self, message): atme = self.is_at_me(message) @@ -37,7 +38,9 @@ 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" @@ -45,16 +48,22 @@ def process_message(self, message): # 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() + ] return Response( confidence=9, text=random.choice(options), - why="%s asked me to choose between the options [%s]" % (who, ", ".join(options)), + why="%s asked me to choose between the options [%s]" + % (who, ", ".join(options)), ) def __str__(self): 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 5f78d6f9..3974ef36 100644 --- a/modules/chatgpt.py +++ b/modules/chatgpt.py @@ -88,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/question_setter.py b/modules/question_setter.py index 33535cf6..8a5cd715 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -221,7 +221,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.", ) ######################### @@ -380,7 +380,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 @@ -393,7 +393,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 ( @@ -404,7 +404,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": @@ -469,7 +469,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": @@ -535,14 +535,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) @@ -606,7 +606,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 7c921cdd..bd7a6cd1 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -153,7 +153,7 @@ async def cb_hardreload_questions(self, message: ServiceMessage) -> Response: return Response( 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" @@ -162,7 +162,7 @@ async def cb_hardreload_questions(self, message: ServiceMessage) -> Response: return Response( 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: @@ -170,7 +170,7 @@ async def cb_refresh_questions(self, message: ServiceMessage) -> Response: return Response( 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" @@ -203,7 +203,7 @@ async def cb_refresh_questions(self, message: ServiceMessage) -> Response: return Response( 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", ) ################### @@ -260,7 +260,7 @@ async def cb_count_questions( return Response( 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}", ) ###################### @@ -297,7 +297,7 @@ async def cb_post_questions( return Response( 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) diff --git a/modules/reply.py b/modules/reply.py index 69b55d68..a6b59ee7 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() @@ -104,14 +111,25 @@ async def post_message(self, message, approvers=None): if message.reference: # if this message is a reply +<<<<<<< HEAD reference = await message.channel.fetch_message(message.reference.id) +======= + reference = await message.channel.fetch_message( + message.reference.message_id + ) +>>>>>>> b74ea4f (changed message.author.name to message.author.display_name except where it seems to be saved to some cache/db) reference_text = reference.clean_content 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 +148,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 +155,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 +175,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 +205,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 +254,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 +276,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 +289,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 2667ea63..669e0f73 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -84,15 +84,20 @@ def process_message(self, message: ServiceMessage): if message.channel.id != bot_private_channel_id: return Response( confidence=10, +<<<<<<< HEAD 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!", +======= + text="Testing is only allowed in #talk-to-stampy", + why=f"{message.author.display_name} wanted to test me outside of the #talk-to-stampy channel which is prohibited!", +>>>>>>> b74ea4f (changed message.author.name to message.author.display_name except where it seems to be saved to some cache/db) ) 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 @@ -106,7 +111,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' ), ) @@ -120,7 +125,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) @@ -128,7 +133,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( diff --git a/servicemodules/discord.py b/servicemodules/discord.py index 1e36e7d4..7424fca2 100644 --- a/servicemodules/discord.py +++ b/servicemodules/discord.py @@ -124,7 +124,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 From 664b25b48faa4d21e9f4983618a06cc13879b68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 20:03:36 +0200 Subject: [PATCH 12/28] added guard in discord.py around bot_dev_channel_id being None --- servicemodules/discord.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/servicemodules/discord.py b/servicemodules/discord.py index 7424fca2..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) From 6e51b70229ebd5203718403926391241b216570e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 20:16:21 +0200 Subject: [PATCH 13/28] changed '|' to ', ' for concatenating alt names --- utilities/help_utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utilities/help_utils.py b/utilities/help_utils.py index ce4e47ff..80a3c308 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -37,7 +37,9 @@ def from_docstring_segment(cls, segment: str) -> CommandHelp: ), "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 = lines[2] if not lines[2].startswith("`") else None + longdescr = ( + "\n".join(l for l in lines[2:] if not l.startswith("`")) or None + ) # lines[2] if not lines[2].startswith("`") else None cases = [l for l in lines[2:] if l.startswith("`")] return cls( name=name, @@ -57,7 +59,7 @@ def names_fmt(self) -> str: """Formatted names: ` (, , ...)`""" names_fmt = self.name if self.alt_names: - names_fmt += " (" + "|".join(self.alt_names) + ")" + names_fmt += " (" + ", ".join(self.alt_names) + ")" return names_fmt def name_match(self, msg_text: str) -> Optional[str]: From c5f980b45d72dc95a78f787cbaed4efc5e51729b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 17 Jun 2023 20:38:30 +0200 Subject: [PATCH 14/28] new QuestionSetter docstring --- modules/question_setter.py | 47 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/modules/question_setter.py b/modules/question_setter.py index 8a5cd715..ea9b3b01 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -1,3 +1,48 @@ +""" +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 (@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" + + +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 import re @@ -8,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, @@ -50,6 +96,7 @@ def __init__(self) -> None: 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]] = {} From eab7bcfb72c291522b4a2540612033c60c96d486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sun, 18 Jun 2023 21:32:45 +0200 Subject: [PATCH 15/28] changed STAMPY_HELP_MSG --- modules/HelpModule.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index b954438b..98ba672a 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -12,6 +12,7 @@ """ import re +from textwrap import dedent from modules.module import Module, Response from utilities.help_utils import ModuleHelp @@ -19,7 +20,12 @@ class HelpModule(Module): - DEFAULT_HELP_RESPONSE = "#TODO: DEFAULT HELP RESPONSE" + 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__() @@ -38,7 +44,7 @@ def process_message(self, message: ServiceMessage) -> Response: if text == "help": return Response( confidence=10, - text=self.DEFAULT_HELP_RESPONSE, + text=self.STAMPY_HELP_MSG, why=f"{message.author.display_name} asked me for generic help", ) if self.re_help.match(text): From 562e581a9b0d91b688f8ad9fa2a2f19a164597a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Mon, 19 Jun 2023 14:04:57 +0200 Subject: [PATCH 16/28] wrote docstring for TestModule --- README.md | 2 +- modules/testModule.py | 38 ++++++++++++++++++++++++++++++++------ utilities/help_utils.py | 10 +++------- 3 files changed, 36 insertions(+), 14 deletions(-) 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/modules/testModule.py b/modules/testModule.py index 669e0f73..48442c1c 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 `#talk-to-stampy` channel. + +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,7 +29,12 @@ 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 @@ -84,13 +113,10 @@ def process_message(self, message: ServiceMessage): if message.channel.id != bot_private_channel_id: return Response( confidence=10, -<<<<<<< HEAD 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!", -======= - text="Testing is only allowed in #talk-to-stampy", - why=f"{message.author.display_name} wanted to test me outside of the #talk-to-stampy channel which is prohibited!", ->>>>>>> b74ea4f (changed message.author.name to message.author.display_name except where it seems to be saved to some cache/db) + # text="Testing is only allowed in #talk-to-stampy", + # why=f"{message.author.display_name} wanted to test me outside of the #talk-to-stampy channel which is prohibited!", ) if not is_bot_dev(message.author): diff --git a/utilities/help_utils.py b/utilities/help_utils.py index 80a3c308..6390f43c 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -87,19 +87,15 @@ class ModuleHelp: @classmethod def from_docstring(cls, module_name: str, docstring: Optional[str]) -> ModuleHelp: if docstring is None: - return cls(module_name=module_name, descr=None, longdescr=None, commands=[]) + return cls(module_name, None, None, []) descr_segment, *command_segments = re.split(r"\n{2,}", docstring.strip()) if "\n" in descr_segment: descr, longdescr = descr_segment.split("\n", 1) else: descr = descr_segment longdescr = None - cmds = [ - CommandHelp.from_docstring_segment(segment) for segment in command_segments - ] - return cls( - module_name=module_name, descr=descr, longdescr=longdescr, commands=cmds - ) + cmds = [CommandHelp.from_docstring_segment(segm) for segm in command_segments] + return cls(module_name, descr, longdescr, cmds) @property def descr_msg(self) -> str: From 0c4677b3c070d44dde2e92d9bfb66a4aab578084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Thu, 22 Jun 2023 14:27:28 +0200 Subject: [PATCH 17/28] reformatted help docstrings --- modules/HelpModule.py | 6 +++--- modules/question_setter.py | 10 +++++----- modules/questions.py | 8 ++++---- modules/testModule.py | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index 98ba672a..287663f0 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -1,11 +1,11 @@ """ Helps you interact with me -list modules -list what modules I have + short descriptions +List modules +List what modules I have + short descriptions `s, list modules` -help +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 diff --git a/modules/question_setter.py b/modules/question_setter.py index ea9b3b01..2b14ac91 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -5,14 +5,14 @@ - 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 (@reviewer, @feedback, @feedback-sketch) +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) +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): @@ -22,18 +22,18 @@ - stands for "looks good to me" -Mark for deletion or as duplicate (del, dup, deletion, duplicate) +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) +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) +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 diff --git a/modules/questions.py b/modules/questions.py index bd7a6cd1..53e2d444 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -1,23 +1,23 @@ """ Querying question database -how many questions (count questions) +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) +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 +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 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` """ diff --git a/modules/testModule.py b/modules/testModule.py index 48442c1c..0b7e69a3 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -3,20 +3,20 @@ 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 `#talk-to-stampy` channel. -test all modules (test yourself) +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 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 one module, Test module Test exactly one Stampy module. `s, test module ` -send a long message +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 ba29c0a1c699c78d3bd7aee8092466f45c529a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Thu, 22 Jun 2023 14:29:48 +0200 Subject: [PATCH 18/28] refactored help_utils and build_help --- build_help.py | 81 ++++++------- utilities/help_utils.py | 251 ++++++++++++++++++++++++++-------------- 2 files changed, 201 insertions(+), 131 deletions(-) diff --git a/build_help.py b/build_help.py index 5cf23642..e03e835c 100644 --- a/build_help.py +++ b/build_help.py @@ -1,64 +1,59 @@ -"""Run this script to update help.md file""" import os +from pathlib import Path import re 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 = """# Stampy Module & Command Help\n\n""" -_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/utilities/help_utils.py b/utilities/help_utils.py index 6390f43c..e0d54276 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -2,34 +2,117 @@ from dataclasses import dataclass import re -from typing import Optional +from typing import Literal, Optional -CommandAliases = tuple[str, ...] -CommandDescr = CommandExample = str -CapabilitiesDict = dict[CommandAliases, tuple[CommandDescr, CommandExample]] +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 + + 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`.""" + # 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 + lines = [self.get_module_name_header(markdown=markdown), self.descr] + if self.longdescr is not None: + lines.append(self.longdescr) + if self.commands: + lines.extend( + cmd.get_help(msg_text=None, markdown=markdown) for cmd in self.commands + ) + return _concatenate_lines(lines, markdown=markdown) @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] - cases: list[str] - img_paths: list[str] # TODO - - @staticmethod - def parse_name_line(line: str) -> tuple[str, list[str]]: - if alt_name_match := re.search(r"(?<=\().+(?=\))", line): - name = line[: alt_name_match.span()[0] - 2].strip() - alt_names = [an.strip() for an in alt_name_match.group().split(",")] - else: - name = line.strip() - alt_names = [] - return name, alt_names + 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 ( @@ -37,92 +120,84 @@ def from_docstring_segment(cls, segment: str) -> CommandHelp: ), "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 - ) # lines[2] if not lines[2].startswith("`") else None - cases = [l for l in lines[2:] if l.startswith("`")] + 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, - cases=cases, - img_paths=[], + 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] - @property - def names_fmt(self) -> str: - """Formatted names: ` (, , ...)`""" - names_fmt = self.name - if self.alt_names: - names_fmt += " (" + ", ".join(self.alt_names) + ")" - return names_fmt - - def name_match(self, msg_text: str) -> Optional[str]: + 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` + + ` (, , ...)` + """ + # if self.alt_names: + # breakpoint() + 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 + + 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) + return _concatenate_lines(lines, markdown=markdown) + + 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"(? str: - msg = f"{self.names_fmt}\n{self.descr}\n" - if self.longdescr: - msg += f"{self.longdescr}\n" - msg += "\n".join(self.cases) - return msg - -@dataclass(frozen=True) -class ModuleHelp: - module_name: str - descr: Optional[str] - longdescr: Optional[str] - commands: list[CommandHelp] - - @classmethod - def from_docstring(cls, module_name: str, docstring: Optional[str]) -> ModuleHelp: - if docstring is None: - return cls(module_name, None, None, []) - descr_segment, *command_segments = re.split(r"\n{2,}", docstring.strip()) - if "\n" in descr_segment: - descr, longdescr = descr_segment.split("\n", 1) - else: - descr = descr_segment - longdescr = None - cmds = [CommandHelp.from_docstring_segment(segm) for segm in command_segments] - return cls(module_name, descr, longdescr, cmds) - - @property - def descr_msg(self) -> str: - if self.descr is None: - return f"- `{self.module_name}`" - return f"- `{self.module_name}`: {self.descr}" - - @property - def module_name_header(self) -> str: - return f"Module `{self.module_name}`" - - def get_help_for_module(self) -> str: - msg = f"{self.module_name_header}\n" - if self.descr is not None: - msg += f"{self.descr}\n" - if self.longdescr is not None: - msg += f"{self.longdescr}\n" - else: - msg += "No module description available\n" - if self.commands: - msg += "\n" + "\n\n".join(cmd.help_msg for cmd in self.commands) - return msg - - def get_help_for_command(self, msg_text: str) -> Optional[str]: - # iterate over commands - for cmd in self.commands: - # if some name of the command matches msg_text - if cmd_name := cmd.name_match(msg_text): - command_help_msg = cmd.help_msg.replace(cmd_name, f"**{cmd_name}**", 1) - return f"{self.module_name_header}\n{command_help_msg}" +def _concatenate_lines(lines: list[str], *, markdown: bool) -> str: + joiner = "\n\n" if markdown else "\n" + return joiner.join(lines) From da0fb361ab362c7cc0e118496af2949d9a2a2e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Thu, 22 Jun 2023 14:30:44 +0200 Subject: [PATCH 19/28] fixed mergeconflict --- modules/reply.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/modules/reply.py b/modules/reply.py index a6b59ee7..fd4b6f32 100644 --- a/modules/reply.py +++ b/modules/reply.py @@ -111,13 +111,7 @@ async def post_message(self, message, approvers=None): if message.reference: # if this message is a reply -<<<<<<< HEAD reference = await message.channel.fetch_message(message.reference.id) -======= - reference = await message.channel.fetch_message( - message.reference.message_id - ) ->>>>>>> b74ea4f (changed message.author.name to message.author.display_name except where it seems to be saved to some cache/db) reference_text = reference.clean_content question_url = reference_text.split("\n")[-1].strip("<> \n") From 363c0483783053822262ab0fe71c9e90ca51882b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Thu, 22 Jun 2023 16:02:35 +0200 Subject: [PATCH 20/28] debugged getting list of modules --- modules/HelpModule.py | 14 ++++++-------- utilities/help_utils.py | 9 +++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index 287663f0..b0cb44bb 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -13,6 +13,7 @@ import re from textwrap import dedent +from typing import cast from modules.module import Module, Response from utilities.help_utils import ModuleHelp @@ -54,7 +55,7 @@ def process_message(self, message: ServiceMessage) -> Response: def list_modules(self) -> str: msg_descrs = sorted( - (mod.help.descr_msg for mod in self.utils.modules_dict.values()), + (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) @@ -63,21 +64,18 @@ async def cb_help(self, text: str, message: ServiceMessage) -> Response: help_content = text[len("help ") :] # iterate over modules for mod in self.utils.modules_dict.values(): - # command help - # TODO: rename attr - if mod_help := mod.help.get_help_for_command(msg_text=help_content): + if cmd_help := mod.help.get_command_help(msg_text=help_content): return Response( confidence=10, - text=mod_help, + text=cmd_help, why=f'{message.author.display_name} asked me for help with "{help_content}"', ) # module help if mod.class_name.casefold() in help_content.casefold(): - # TODO: help is empty - msg_text = mod.help.get_help_for_module() + mod_help = cast(str, mod.help.get_module_help(markdown=False)) return Response( confidence=10, - text=msg_text, + text=mod_help, why=f"{message.author.display_name} asked me for help with module `{mod.class_name}`", ) diff --git a/utilities/help_utils.py b/utilities/help_utils.py index e0d54276..68677c11 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -44,6 +44,13 @@ def from_docstring(cls, module_name: str, docstring: Optional[str]) -> ModuleHel 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: @@ -52,6 +59,8 @@ def get_module_name_header(self, *, markdown: bool) -> str: 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 From 699e84137d3e25fdfba84ccafd813557c5d6db29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 13:58:37 +0200 Subject: [PATCH 21/28] updated FILE_HEADER in build_help --- build_help.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build_help.py b/build_help.py index e03e835c..2494202b 100644 --- a/build_help.py +++ b/build_help.py @@ -1,6 +1,7 @@ import os from pathlib import Path import re +from textwrap import dedent from typing import Optional from utilities.help_utils import ModuleHelp @@ -9,7 +10,14 @@ ModuleName = Docstring = str -FILE_HEADER = """# Stampy Module & Command Help\n\n""" +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. + + """ +) def extract_docstring(code: str) -> Optional[Docstring]: From 98809b80f513689cbb45b5f8351f7930615dbbd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 14:22:15 +0200 Subject: [PATCH 22/28] refactored cb_help and wrote tests for HelpModule --- modules/HelpModule.py | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index b0cb44bb..3a2ea25b 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -13,9 +13,8 @@ import re from textwrap import dedent -from typing import cast -from modules.module import Module, Response +from modules.module import IntegrationTest, Module, Response from utilities.help_utils import ModuleHelp from utilities.serviceutils import ServiceMessage @@ -60,8 +59,8 @@ def list_modules(self) -> str: ) return "I have the following modules:\n" + "\n".join(msg_descrs) - async def cb_help(self, text: str, message: ServiceMessage) -> Response: - help_content = text[len("help ") :] + async def cb_help(self, msg_text: str, message: ServiceMessage) -> Response: + help_content = msg_text[len("help ") :] # iterate over modules for mod in self.utils.modules_dict.values(): if cmd_help := mod.help.get_command_help(msg_text=help_content): @@ -72,12 +71,14 @@ async def cb_help(self, text: str, message: ServiceMessage) -> Response: ) # module help if mod.class_name.casefold() in help_content.casefold(): - mod_help = cast(str, mod.help.get_module_help(markdown=False)) - return Response( - confidence=10, - text=mod_help, - why=f"{message.author.display_name} asked me for help with module `{mod.class_name}`", - ) + mod_help = mod.help.get_module_help(markdown=False) + why = f"{message.author.display_name} asked me for help with module `{mod.class_name}`" + if mod_help is None: + msg_text = f"No help for module `{mod.class_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( @@ -85,3 +86,23 @@ async def cb_help(self, text: str, message: ServiceMessage) -> Response: 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=f"**Module `{mod_name}`**", + ) + for mod_name, mod in self.utils.modules_dict.items() + if hasattr(mod, "help") + ] + 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 From 9cdf7bd6c1799970c98101b9c1a28f18500577c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 14:22:43 +0200 Subject: [PATCH 23/28] minor fixes --- modules/questions.py | 14 +++++++------- modules/testModule.py | 13 +++++++++---- utilities/help_utils.py | 29 +++++++++++++++-------------- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index 53e2d444..c1a02c2c 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -17,7 +17,7 @@ `s, ` - get info about last question `s, ` - get tinfo about the question under that GDoc link -Refresh questions, Reload questions) +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` """ @@ -41,6 +41,12 @@ 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: @@ -51,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() diff --git a/modules/testModule.py b/modules/testModule.py index 0b7e69a3..bca50364 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -1,7 +1,7 @@ """ 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 `#talk-to-stampy` channel. +**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. @@ -38,6 +38,7 @@ 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 @@ -64,9 +65,11 @@ 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): + # breakpoint() if message.clean_content == "s, send a long message": if not is_bot_dev(message.author): return Response( @@ -115,8 +118,6 @@ def process_message(self, message: ServiceMessage): 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!", - # text="Testing is only allowed in #talk-to-stampy", - # why=f"{message.author.display_name} wanted to test me outside of the #talk-to-stampy channel which is prohibited!", ) if not is_bot_dev(message.author): @@ -173,12 +174,16 @@ def is_at_module(self, message: ServiceMessage) -> bool: """The message is directed at this module if its service is supported and it contains one of the test phrases """ + # breakpoint() if ( hasattr(message, "service") 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) diff --git a/utilities/help_utils.py b/utilities/help_utils.py index 68677c11..cfc4819b 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import re -from typing import Literal, Optional +from typing import Literal, Optional, overload Format = Literal["markdown", "discord"] @@ -55,7 +55,7 @@ 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}`" + 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`.""" @@ -75,14 +75,15 @@ def get_module_help(self, *, markdown: bool) -> Optional[str]: """ if self.empty: return - lines = [self.get_module_name_header(markdown=markdown), self.descr] + segments = [self.get_module_name_header(markdown=markdown), self.descr] if self.longdescr is not None: - lines.append(self.longdescr) + segments.append(self.longdescr) + segments = ["\n".join(segments).replace("\n", "\n\n" if markdown else "\n")] if self.commands: - lines.extend( + segments.extend( cmd.get_help(msg_text=None, markdown=markdown) for cmd in self.commands ) - return _concatenate_lines(lines, markdown=markdown) + return "\n\n".join(segments) @dataclass(frozen=True) @@ -170,8 +171,6 @@ def get_fmt_name_lines( ` (, , ...)` """ - # if self.alt_names: - # breakpoint() out = self.name if markdown: out = f"### {out}" @@ -184,6 +183,12 @@ def get_fmt_name_lines( 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: @@ -198,15 +203,11 @@ def get_help(self, msg_text: Optional[str], markdown: bool) -> Optional[str]: if self.longdescr: lines.append(self.longdescr) lines.extend(self.examples) - return _concatenate_lines(lines, markdown=markdown) + 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"(? str: - joiner = "\n\n" if markdown else "\n" - return joiner.join(lines) From 4f343ebc0fb8ef82a13c5434459d9893769e09b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 14:34:31 +0200 Subject: [PATCH 24/28] added tests for helpless modules and fixed regex in helped modules --- modules/HelpModule.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index 3a2ea25b..c37fbb92 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -92,17 +92,33 @@ def test_cases(self) -> list[IntegrationTest]: module_help_tests = [ self.create_integration_test( test_message=f"help {mod_name}", - expected_regex=f"**Module `{mod_name}`**", + expected_regex=rf"\*\*Module `{mod_name}`\*\*", ) for mod_name, mod in self.utils.modules_dict.items() - if hasattr(mod, "help") + if not mod.help.empty ] - return [ - self.create_integration_test( - test_message="list modules", - expected_regex=r"I have the following modules:(\n- `\w+`[^\n]*)+", - ), + helpless_module_names = [ + mod_name + for mod_name, mod in self.utils.modules_dict.items() + if mod.help.empty + ][:3] + helpless_module_tests = [ self.create_integration_test( - test_message="help", expected_response=self.STAMPY_HELP_MSG - ), - ] + module_help_tests + test_message=f"help {mod_name}", + expected_regex=f"No help for module `{mod_name}`", + ) + for mod_name in helpless_module_names + ] + 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 + ) From 7dd2f0d91b678f58641371be6b6111a50eeb67f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 14:46:20 +0200 Subject: [PATCH 25/28] refactored helpless_module_tests to use islice and sorted modules in cb_help in reverse --- modules/HelpModule.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/modules/HelpModule.py b/modules/HelpModule.py index c37fbb92..58d4d0b4 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -10,7 +10,7 @@ `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 @@ -61,8 +61,8 @@ def list_modules(self) -> str: async def cb_help(self, msg_text: str, message: ServiceMessage) -> Response: help_content = msg_text[len("help ") :] - # iterate over modules - for mod in self.utils.modules_dict.values(): + # 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, @@ -70,11 +70,11 @@ async def cb_help(self, msg_text: str, message: ServiceMessage) -> Response: why=f'{message.author.display_name} asked me for help with "{help_content}"', ) # module help - if mod.class_name.casefold() in help_content.casefold(): + 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.class_name}`" + 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.class_name}`" + msg_text = f"No help for module `{mod_name}`" why += " but help is not written for it" else: msg_text = mod_help @@ -97,18 +97,19 @@ def test_cases(self) -> list[IntegrationTest]: for mod_name, mod in self.utils.modules_dict.items() if not mod.help.empty ] - helpless_module_names = [ - mod_name - for mod_name, mod in self.utils.modules_dict.items() - if mod.help.empty - ][:3] - helpless_module_tests = [ - self.create_integration_test( - test_message=f"help {mod_name}", - expected_regex=f"No help for module `{mod_name}`", + 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, ) - for mod_name in helpless_module_names - ] + ) return ( [ self.create_integration_test( From 29de7d92017ed6635cb2d40071e512b7398e4324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 15:34:16 +0200 Subject: [PATCH 26/28] fixed issue #297 --- modules/question_setter.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/question_setter.py b/modules/question_setter.py index 2b14ac91..d9030eba 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -90,7 +90,7 @@ 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) @@ -283,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, @@ -330,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: From 87ce9cc185a6de8a0f333e0b1f44e63fd2519cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 15:38:30 +0200 Subject: [PATCH 27/28] removed breakpoints --- modules/testModule.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/testModule.py b/modules/testModule.py index bca50364..9c13b229 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -69,7 +69,6 @@ def __init__(self): self.sent_test: list[IntegrationTest] = [] def process_message(self, message: ServiceMessage): - # breakpoint() if message.clean_content == "s, send a long message": if not is_bot_dev(message.author): return Response( @@ -174,7 +173,6 @@ def is_at_module(self, message: ServiceMessage) -> bool: """The message is directed at this module if its service is supported and it contains one of the test phrases """ - # breakpoint() if ( hasattr(message, "service") and message.service not in self.SUPPORTED_SERVICES From 5a2513f4583dc2971132143c9b87e34569edd56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 15:46:36 +0200 Subject: [PATCH 28/28] minor fixes of types and user names --- modules/Factoids.py | 3 +-- modules/Random.py | 12 +++++++----- modules/Silly.py | 10 ++-------- modules/testModule.py | 2 +- 4 files changed, 11 insertions(+), 16 deletions(-) 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/Random.py b/modules/Random.py index bfe90379..05092512 100644 --- a/modules/Random.py +++ b/modules/Random.py @@ -1,6 +1,7 @@ import re import random from modules.module import Module, Response +from utilities.serviceutils import ServiceMessage from utilities.utilities import Utilities @@ -8,10 +9,10 @@ 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): @@ -44,7 +45,7 @@ def process_message(self, message): ) # "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( @@ -59,12 +60,13 @@ def process_message(self, message): 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/testModule.py b/modules/testModule.py index 9c13b229..df8bbf4c 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -116,7 +116,7 @@ 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):