diff --git a/README.md b/README.md index 48bc54ba..4aca413c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Not required: - `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. - `BE_SHY`: Stamp won't respond when the message isn't specifically to him. - `CHANNEL_WHITELIST`: channels Stampy is allowed to respond to messages in +- `NOT_ROB_SERVER`: If `True`, Rob Miles server-specific stuff is disabled. Servers other than Rob Miles Discord Server and Stampy Test Server should set it to `1`. Otherwise some errors are likely to occur. Specific modules (excluding LLM stuff): diff --git a/api/coda.py b/api/coda.py index a4eac3d7..07ab965f 100644 --- a/api/coda.py +++ b/api/coda.py @@ -14,12 +14,12 @@ parse_question_row, QuestionRow, QuestionStatus, - DEFAULT_DATE, ) from config import ENVIRONMENT_TYPE, coda_api_token from utilities import is_in_testing_mode, Utilities from utilities.discordutils import DiscordUser from utilities.serviceutils import ServiceMessage +from utilities.time_utils import DEFAULT_DATE from utilities.utilities import fuzzy_contains, get_user_handle, shuffle_df if TYPE_CHECKING: @@ -57,19 +57,8 @@ def __init__(self): self.class_name = "Coda API" self.log = get_logger() self.last_question_id: Optional[str] = None - self.questions_df = pd.DataFrame( - columns=[ - "id", - "title", - "url", - "status", - "tags", - "last_asked_on_discord", - "alternate_phrasings", - "row", - ] - ) - + # pylint:disable=no-member + self.questions_df = pd.DataFrame(columns=list(QuestionRow.__required_keys__)) # fmt:skip if is_in_testing_mode(): return @@ -170,15 +159,17 @@ def update_questions_cache(self) -> tuple[list[QuestionRow], list[QuestionRow]]: if row["id"] not in self.questions_df.index: new_questions.append(row) else: - df_row = self.questions_df.loc[row["id"]] - df_row["last_asked_on_discord"] = row["last_asked_on_discord"] - df_row["status"] = row["status"] - df_row["tags"].clear() - df_row["tags"].extend(row["tags"]) - df_row["alternate_phrasings"].clear() - df_row["alternate_phrasings"].extend(row["alternate_phrasings"]) - df_row["title"] = row["title"] - df_row["url"] = row["url"] + self.questions_df.at[row["id"], "title"] = row["title"] + self.questions_df.at[row["id"], "url"] = row["url"] + self.questions_df.at[row["id"], "status"] = row["status"] + + self.questions_df.at[row["id"], "tags"].clear() + self.questions_df.at[row["id"], "tags"].extend(row["tags"]) + self.questions_df.at[row["id"], "alternate_phrasings"].clear() + self.questions_df.at[row["id"], "alternate_phrasings"].extend(row["alternate_phrasings"]) # fmt:skip + + self.questions_df.at[row["id"], "doc_last_edited"] = row["doc_last_edited"] # fmt:skip + self.questions_df.at[row["id"], "last_asked_on_discord"] = row["last_asked_on_discord"] # fmt:skip deleted_question_ids = sorted( set(self.questions_df.index.tolist()) - question_ids @@ -230,9 +221,8 @@ def get_questions_by_gdoc_links(self, urls: list[str]) -> list[QuestionRow]: ) ] - questions_queried = cast( - list[QuestionRow], questions_df_queried.to_dict(orient="records") - ) + questions_queried = self.q_df_to_rows(questions_df_queried) + # If some links were not recognized, refresh cache and look into the new questions if len(questions_df_queried) < len(urls): new_questions, _ = self.update_questions_cache() @@ -276,7 +266,7 @@ def update_question_status( question["row"], make_updated_cells({"Status": status}) ) # update local cache - self.questions_df.loc[question["id"]]["status"] = status + self.questions_df.at[question["id"], "status"] = status def update_question_last_asked_date( self, question: QuestionRow, current_time: datetime @@ -290,7 +280,7 @@ def update_question_last_asked_date( make_updated_cells({"Last Asked On Discord": current_time.isoformat()}), ) # update local cache - self.questions_df.loc[question["id"]]["last_asked_on_discord"] = current_time + self.questions_df.at[question["id"], "last_asked_on_discord"] = current_time def _reset_dates(self) -> None: """Reset all questions' dates (util, not to be used by Stampy)""" @@ -306,8 +296,8 @@ def update_question_tags(self, question: QuestionRow, new_tags: list[str]) -> No self.doc.get_table(self.STAMPY_ANSWERS_API_ID).update_row( question["row"], make_updated_cells({"Tags": new_tags}) ) - self.questions_df.loc[question["id"]]["tags"].clear() - self.questions_df.loc[question["id"]]["tags"].extend(new_tags) + self.questions_df.at[question["id"], "tags"].clear() + self.questions_df.at[question["id"], "tags"].extend(new_tags) self.last_question_id = question["id"] # Alternate phrasings @@ -318,10 +308,8 @@ def update_question_altphr( self.doc.get_table(self.STAMPY_ANSWERS_API_ID).update_row( question["row"], make_updated_cells({"Alternate Phrasings": new_alt_phrs}) ) - self.questions_df.loc[question["id"]]["alternate_phrasings"].clear() - self.questions_df.loc[question["id"]]["alternate_phrasings"].extend( - new_alt_phrs - ) + self.questions_df.at[question["id"], "alternate_phrasings"].clear() + self.questions_df.at[question["id"], "alternate_phrasings"].extend(new_alt_phrs) self.last_question_id = question["id"] ############### @@ -398,7 +386,7 @@ async def query_for_questions( if least_recently_asked_unpublished: questions_df = questions_df.query("status != 'Live on site'") elif status is not None: - questions_df = questions_df.query("status == @status") + questions_df = questions_df.query(f"status == '{status}'") # if tag was specified, filter for questions having that tag questions_df = filter_on_tag(questions_df, tag) @@ -419,8 +407,7 @@ async def query_for_questions( questions_df = questions_df.sort_values("last_asked_on_discord", ascending=False).iloc[: min(limit, 5)] # fmt:skip if questions_df.empty: return [] - questions = questions_df.to_dict(orient="records") - return cast(list[QuestionRow], questions) + return self.q_df_to_rows(questions_df) ResponseText = ResponseWhy = str @@ -549,6 +536,10 @@ def get_all_statuses(self) -> list[str]: raise AssertionError(msg) return sorted(coda_status_vals) + @staticmethod + def q_df_to_rows(questions_df: pd.DataFrame) -> list[QuestionRow]: + return cast(list[QuestionRow], questions_df.to_dict(orient="records")) + def filter_on_tag(questions_df: pd.DataFrame, tag: Optional[str]) -> pd.DataFrame: if tag is None: @@ -564,6 +555,5 @@ def get_least_recently_asked_on_discord( questions: pd.DataFrame, ) -> pd.DataFrame: """Get all questions with oldest date and shuffle them""" - # pylint:disable=unused-variable oldest_date = questions["last_asked_on_discord"].min() - return questions.query("last_asked_on_discord == @oldest_date") + return questions.query(f"last_asked_on_discord == '{oldest_date}'") diff --git a/api/utilities/coda_utils.py b/api/utilities/coda_utils.py index 93783a1e..8b4da91e 100644 --- a/api/utilities/coda_utils.py +++ b/api/utilities/coda_utils.py @@ -5,17 +5,7 @@ from codaio import Cell, Row - -DEFAULT_DATE = datetime(1, 1, 1, 0) - - -def adjust_date(date_str: str) -> datetime: - """If date is in isoformat, parse it. - Otherwise, assign earliest date possible. - """ - if not date_str: - return DEFAULT_DATE - return datetime.fromisoformat(date_str.split("T")[0]) +from utilities.time_utils import adjust_date def parse_question_row(row: Row) -> QuestionRow: @@ -29,6 +19,7 @@ def parse_question_row(row: Row) -> QuestionRow: # remove empty strings tags = [tag for tag in row_dict["Tags"].split(",") if tag] last_asked_on_discord = adjust_date(row_dict["Last Asked On Discord"]) + doc_last_edited = adjust_date(row_dict["Doc Last Edited"]) alternate_phrasings = [ alt for alt in row_dict["Alternate Phrasings"].split(",") if alt ] @@ -38,8 +29,9 @@ def parse_question_row(row: Row) -> QuestionRow: "url": url, "status": status, "tags": tags, - "last_asked_on_discord": last_asked_on_discord, "alternate_phrasings": alternate_phrasings, + "last_asked_on_discord": last_asked_on_discord, + "doc_last_edited": doc_last_edited, "row": row, } @@ -62,8 +54,9 @@ class QuestionRow(TypedDict): url: str status: str tags: list[str] - last_asked_on_discord: datetime alternate_phrasings: list[str] + last_asked_on_discord: datetime + doc_last_edited: datetime row: Row @@ -87,3 +80,9 @@ class QuestionRow(TypedDict): "duplicated": "Duplicate", "published": "Live on site", } + +REVIEW_STATUSES: set[QuestionStatus] = { + "Bulletpoint sketch", + "In progress", + "In review", +} diff --git a/build_help.py b/build_help.py index 2494202b..eb1a82f5 100644 --- a/build_help.py +++ b/build_help.py @@ -1,67 +1,12 @@ -import os from pathlib import Path -import re -from textwrap import dedent -from typing import Optional -from utilities.help_utils import ModuleHelp - -MODULES_PATH = Path("modules/") - -ModuleName = Docstring = str - -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]: - 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 - - -_re_module_name = re.compile(r"(?<=class\s)\w+(?=\(Module\):)", re.I) - - -def extract_module_name(code: str) -> Optional[ModuleName]: - if match := _re_module_name.search(code): - return match.group() - - -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 +from utilities.help_utils import build_help_md def main() -> None: - 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) + help_md = build_help_md(Path("modules")) with open("help.md", "w", encoding="utf-8") as f: - f.write(help_txt) + f.write(help_md) if __name__ == "__main__": diff --git a/config.py b/config.py index 5bfb64e1..3aec6153 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,5 @@ import os -from typing import Literal, Optional, TypeVar, Union, cast, get_args, overload, Any +from typing import Literal, TypeVar, Optional, Union, cast, get_args, overload import dotenv from structlog import get_logger @@ -51,18 +51,19 @@ def getenv_bool(env_var: str) -> bool: return e != "UNDEFINED" -Tt = TypeVar("Tt") +# fmt:off @overload def getenv_unique_set(var_name: str) -> frozenset:... @overload def getenv_unique_set(var_name: str, default: frozenset) -> frozenset:... @overload -def getenv_unique_set(var_name: str, default: Literal[None]) \ - -> Optional[frozenset[str]]:... +def getenv_unique_set(var_name: str, default: None) -> Optional[frozenset[str]]:... @overload -def getenv_unique_set(var_name: str, default: Tt) \ - -> Union[frozenset[str], Tt]:... -def getenv_unique_set(var_name: str, default = frozenset()): +def getenv_unique_set(var_name: str, default: T) -> Union[frozenset[str], T]:... +# fmt:on + + +def getenv_unique_set(var_name: str, default: T = frozenset()) -> Union[frozenset, T]: l = getenv(var_name, default="EMPTY_SET").split(" ") if l == ["EMPTY_SET"]: return default @@ -217,7 +218,9 @@ def getenv_unique_set(var_name: str, default = frozenset()): use_helicone = getenv_bool("USE_HELICONE") llm_prompt = getenv("LLM_PROMPT", default=stampy_default_prompt) be_shy = getenv_bool("BE_SHY") - channel_whitelist: Optional[frozenset[str]] = getenv_unique_set("CHANNEL_WHITELIST", None) + channel_whitelist: Optional[frozenset[str]] = getenv_unique_set( + "CHANNEL_WHITELIST", None + ) discord_token = getenv("DISCORD_TOKEN") database_path = getenv("DATABASE_PATH") @@ -227,6 +230,10 @@ def getenv_unique_set(var_name: str, default = frozenset()): slack_app_token = getenv("SLACK_APP_TOKEN", default=None) slack_bot_token = getenv("SLACK_BOT_TOKEN", default=None) +not_rob_server = getenv_bool("NOT_ROB_SERVER") +is_rob_server = not not_rob_server + + # VARIABLE VALIDATION bot_reboot_options = frozenset(["exec", False]) assert ( diff --git a/database/Factoids.db b/database/Factoids.db index 99997049..402b9c43 100644 Binary files a/database/Factoids.db and b/database/Factoids.db differ diff --git a/modules/HelpModule.py b/modules/HelpModule.py index 008d8ffc..45829aea 100644 --- a/modules/HelpModule.py +++ b/modules/HelpModule.py @@ -15,7 +15,6 @@ from textwrap import dedent from modules.module import IntegrationTest, Module, Response -from utilities.help_utils import ModuleHelp from utilities.serviceutils import ServiceMessage @@ -29,7 +28,6 @@ class HelpModule(Module): def __init__(self): super().__init__() - self.help = ModuleHelp.from_docstring(self.class_name, __doc__) self.re_help = re.compile(r"help \w+", re.I) def process_message(self, message: ServiceMessage) -> Response: diff --git a/modules/Silly.py b/modules/Silly.py index 6a087356..d26feb89 100644 --- a/modules/Silly.py +++ b/modules/Silly.py @@ -1,9 +1,7 @@ """ Provides quirky responses for some pre-programmed cases, most unprompted. - If a message from this module interrupts a desired behavior, re-send your previous message and the joke won't trigger. - - `s, say X`: `X!` - XKCD 37: `that's a weird-ass story` -> `that's a weird ass-story` - How original: `Welcome our new X` -> @@ -57,13 +55,15 @@ def process_message(self, message: ServiceMessage) -> Response: 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() - if atme and is_bot_dev(message.author) \ - and text.lower() == "show me how exceptional you are!": + if ( + atme + and is_bot_dev(message.author) + and text.lower() == "show me how exceptional you are!" + ): class SillyError(Exception): pass @@ -266,7 +266,9 @@ class SillyError(Exception): result = datetime.datetime.now().strftime("%H:%M") else: result = random.choice(["Time to buy a watch", "Showtime!"]) - return Response(confidence=Conf, text=result, why=f"{who} asked for the time") + return Response( + confidence=Conf, text=result, why=f"{who} asked for the time" + ) # If you want pictures of spiderman, Stampy's got you imagere = re.compile( @@ -318,7 +320,7 @@ class SillyError(Exception): for c in "AEIOU": result = result.replace(c, "O") return Response( - confidence=Conf-2, + confidence=Conf - 2, text=result, why="O don't know, O thooght ot woold bo fonny", ) @@ -366,7 +368,7 @@ class SillyError(Exception): except: result = random.choice(options) return Response( - confidence=Conf+2, + confidence=Conf + 2, text=r"I choose {random.choice(options)}", why="%s implied a choice between the options [%s]" % (who, ", ".join(options)), diff --git a/modules/module.py b/modules/module.py index ee038225..aa33747c 100644 --- a/modules/module.py +++ b/modules/module.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +import inspect import re import random from typing import Callable, Iterable, Literal, Optional, TypedDict, Union @@ -130,23 +131,12 @@ class Module: 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 __init__(self): self.utils = Utilities.get_instance() self.log = get_logger() self.re_replace = re.compile(r".*?({{.+?}})") - self.help = ModuleHelp.from_docstring(self.class_name, __doc__) + module_docstring = inspect.getmodule(self).__doc__ + self.help = ModuleHelp.from_docstring(self.class_name, module_docstring) 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 d9030eba..30e53fe0 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -1,9 +1,9 @@ """ 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)). +- All server members can contribute to AI Safety Questions and ask for feedback. +- Only `@bot dev`s, `@editor`s, and `@reviewer`s can change question status by other commands. +- Only `@reviewers` can change status of questions to and from `Live on site` (including accepting review requests). Review request, @reviewer, @feedback, @feedback-sketch Request a review on an answer you wrote/edited @@ -53,7 +53,6 @@ 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, @@ -96,7 +95,6 @@ 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]] = {} diff --git a/modules/questions.py b/modules/questions.py index c1a02c2c..a47c6477 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -1,5 +1,8 @@ """ -Querying question database +Querying question database. +This module is also responsible for automatically posting questions coda questions to channels +1. `Not started`: Every 6 hours Stampy posts to `#general` a question with status `Not started`, chosen randomly from those that were least recently posted to Discord. Stampy doesn't post, if the last message in `#general` was this kind of autoposted question. +2. **WIP**: Every Monday, Wednesday, and Friday, sometime between 8 and 12 AM, Stampy posts to `#meta-editing` 1 to 3 questions with status `In review`, `In progress` or `Bulletpoint sketch` that have not been edited for longer than a week. Similarly, he skips if the last message in `#meta-editing` was this one. How many questions, Count questions Count questions, optionally queried by status and/or tag @@ -23,12 +26,12 @@ """ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import random import re from typing import cast, Optional -from discord import Thread +from discord.channel import TextChannel from dotenv import load_dotenv from api.coda import ( @@ -36,17 +39,20 @@ filter_on_tag, get_least_recently_asked_on_discord, ) -from api.utilities.coda_utils import QuestionRow, QuestionStatus -from config import coda_api_token -from servicemodules.discordConstants import general_channel_id +from api.utilities.coda_utils import REVIEW_STATUSES, QuestionRow, QuestionStatus +from config import coda_api_token, is_rob_server +from servicemodules.discordConstants import ( + general_channel_id, + meta_editing_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 +from utilities.time_utils import get_last_monday if coda_api_token is not None: @@ -63,7 +69,8 @@ class Questions(Module): - AUTOPOST_QUESTION_INTERVAL = timedelta(hours=6) + AUTOPOST_NOT_STARTED_MSG_PREFIX = "Recently I've been wondering..." + AUTOPOST_STAGNANT_MSG_PREFIX = "Would any of you like to pick these up?" @staticmethod def is_available() -> bool: @@ -79,14 +86,38 @@ 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 - self.last_posted_time: datetime = ( - datetime.now() - self.AUTOPOST_QUESTION_INTERVAL / 2 + ################### + # Autoposting # + ################### + + # How often Stampy posts random not started questions to `#general` + self.not_started_question_autopost_interval = timedelta(hours=6) + + # Time of last (attempted) autopost of not started question + self.last_not_started_autopost_attempt_dt = ( + datetime.now() - self.not_started_question_autopost_interval / 2 ) - # regexes + + # Date of last (attempted) autopost of WIP question(s) + self.last_wip_autopost_attempt_date: date = get_last_monday().date() + + # Max number of WIP questions to be autoposted + self.wip_autopost_limit: int = 3 + + if is_rob_server: + @self.utils.client.event # fmt:skip + async def on_socket_event_type(_event_type) -> None: + if self.is_time_for_autopost_not_started(): + await self.autopost_not_started() + if self.is_time_for_autopost_wip(): + await self.autopost_wip() + + ############### + # Regexes # + ############### + self.re_post_question = re.compile( r""" (?:get|post|next) # get / post / next @@ -114,17 +145,6 @@ def __init__(self) -> None: r"(reload|fetch|load|update|refresh)(\s+new)?\s+q(uestions)?", re.I ) - # Was the last question that was posted, automatically posted by Stampy? - self.last_question_autoposted = False - - # Register `post_random_oldest_question` to be triggered every after 6 hours of no question posting - @self.utils.client.event - async def on_socket_event_type(event_type) -> None: - if ( - self.last_posted_time < datetime.now() - self.AUTOPOST_QUESTION_INTERVAL - ) and not self.last_question_autoposted: - await self.post_random_oldest_question(event_type) - def process_message(self, message: ServiceMessage) -> Response: if not (text := self.is_at_me(message)): return Response() @@ -243,7 +263,7 @@ async def cb_count_questions( # if status and/or tag specified, filter accordingly if status: - questions_df = questions_df.query("status == @status") + questions_df = questions_df.query(f"status == '{status}'") if tag: questions_df = filter_on_tag(questions_df, tag) @@ -323,10 +343,6 @@ async def cb_post_questions( response_text += f"\n{make_post_question_message(q)}" self.coda_api.update_question_last_asked_date(q, current_time) - # update caches - self.last_posted_time = current_time - self.last_question_autoposted = False - # if there is exactly one question, remember its ID if len(questions) == 1: self.coda_api.last_question_id = questions[0]["id"] @@ -337,48 +353,136 @@ async def cb_post_questions( why=why, ) - async def post_random_oldest_question(self, _event_type) -> None: - """Post random oldest not started question. - Triggered automatically six hours after non-posting any question - (unless the last was already posted automatically using this method). - """ - # get channel #general - channel = cast(Thread, self.utils.client.get_channel(int(general_channel_id))) + def is_time_for_autopost_not_started(self) -> bool: + return ( + self.last_not_started_autopost_attempt_dt + < datetime.now() - self.not_started_question_autopost_interval + ) + + async def last_msg_in_general_was_autoposted(self) -> bool: + channel = cast( + TextChannel, self.utils.client.get_channel(int(general_channel_id)) + ) + async for msg in channel.history(limit=1): + if msg.content.startswith(self.AUTOPOST_NOT_STARTED_MSG_PREFIX): + return True + return False + + async def autopost_not_started(self) -> None: + """Choose a random question from the oldest not started questions and post to `#general` channel""" + current_time = datetime.now() + self.last_not_started_autopost_attempt_dt = current_time - # query for questions with status "Not started" and not tagged as "Stampy" - questions_df_filtered = self.coda_api.questions_df.query( - "status == 'Not started'" + if await self.last_msg_in_general_was_autoposted(): + self.log.info( + self.class_name, + msg="Last message in #general was an autoposted question with status `Not started` -> skipping autoposting", + ) + return + + self.log.info( + self.class_name, + msg="Autoposting a question with status `Not started` to #general", ) - questions_df_filtered = questions_df_filtered[ - questions_df_filtered["tags"].map(lambda tags: "Stampy" not in tags) + questions_df = self.coda_api.questions_df.query("status == 'Not started'") + questions_df = questions_df[ + questions_df["tags"].map(lambda tags: "Stampy" not in tags) ] - # choose at random from least recently asked ones - question = cast( - QuestionRow, - random.choice( - get_least_recently_asked_on_discord(questions_df_filtered).to_dict( - orient="records" - ), - ), + if questions_df.empty: + self.log.info( + self.class_name, + msg='Found no questions with status `Not started` without tag "Stampy"', + ) + return + + question = random.choice( + self.coda_api.q_df_to_rows( + get_least_recently_asked_on_discord(questions_df) + ) ) - # update in coda - current_time = datetime.now() - self.coda_api.update_question_last_asked_date(question, current_time) + channel = cast( + TextChannel, self.utils.client.get_channel(int(general_channel_id)) + ) - # update caches + msg = f"{self.AUTOPOST_NOT_STARTED_MSG_PREFIX}\n\n{make_post_question_message(question)}" + self.coda_api.update_question_last_asked_date(question, current_time) self.coda_api.last_question_id = question["id"] - self.last_posted_time = current_time - self.last_question_autoposted = True - # log + await channel.send(msg) + + def is_time_for_autopost_wip(self) -> bool: + now = datetime.now() + return ( + now.weekday() in (0, 2, 4) # Monday, Wednesday, or Friday + and 8 <= now.hour <= 12 # between 08:00 and 12:00 + and self.last_wip_autopost_attempt_date + != now.date() # Wasn't posted today yet + ) + + async def last_msg_in_meta_editing_was_autoposted(self) -> bool: + channel = cast( + TextChannel, self.utils.client.get_channel(int(meta_editing_channel_id)) + ) + async for msg in channel.history(limit=1): + if msg.content.startswith(self.AUTOPOST_STAGNANT_MSG_PREFIX): + return True + return False + + async def autopost_wip(self) -> None: + """Post up to a specified number of questions that have been worked on but not touched for longer than a week + to #meta-editing channel.""" + today = date.today() + self.last_wip_autopost_attempt_date = today + + if await self.last_msg_in_meta_editing_was_autoposted(): + self.log.info( + self.class_name, + msg="Last message in `#meta-editing` was one or more autoposted WIP question(s) -> skipping autoposting", + ) + return + + week_ago = today - timedelta(days=7) + question_limit = random.randint(1, self.wip_autopost_limit) + + questions_df = self.coda_api.questions_df.query( + f"doc_last_edited <= '{week_ago}'" + ) + questions_df = ( + questions_df[ + questions_df["status"].map(lambda status: status in REVIEW_STATUSES) + ] + .sort_values(["last_asked_on_discord", "doc_last_edited"]) + .head(question_limit) + ) + + if questions_df.empty: + self.log.info( + self.class_name, + msg=f"Found no questions with status from {REVIEW_STATUSES} with docs edited one week ago or earlier", + ) + return + + questions = self.coda_api.q_df_to_rows(questions_df) + self.log.info( self.class_name, - msg="Posting a random, least recent, not started question to #general", + msg=f"Posting {len(questions)} WIP questions to #meta-editing", ) - # send to channel - await channel.send(make_post_question_message(question)) + if len(questions) == 1: + self.coda_api.last_question_id = questions[0]["id"] + + channel = cast( + TextChannel, self.utils.client.get_channel(int(meta_editing_channel_id)) + ) + current_time = datetime.now() + msg = self.AUTOPOST_STAGNANT_MSG_PREFIX + "\n\n" + for q in questions: + msg += f"{make_post_question_message(q, with_status=True, with_doc_last_edited=True)}\n" + self.coda_api.update_question_last_asked_date(q, current_time) + + await channel.send(msg) ######################### # Get question info # @@ -551,15 +655,30 @@ def __str__(self): ############# -def make_post_question_message(question_row: QuestionRow) -> str: +def make_post_question_message( + question: QuestionRow, + *, + with_status: bool = False, + with_doc_last_edited: bool = False, +) -> str: """Make message for posting a question into a Discord channel ``` - "" + + (optional) ``` """ - return '"' + question_row["title"] + '"' + "\n" + question_row["url"] + msg = question["title"] + "\n" + if with_status: + msg += f"Status: `{question['status']}`." + if with_doc_last_edited: + msg += f" Last edited: `{question['doc_last_edited'].date()}`." + msg += "\n" + elif with_doc_last_edited: + msg += f"Last edited: `{question['doc_last_edited'].date()}`\n" + msg += question["url"] + return msg def make_status_and_tag_response_text( diff --git a/modules/testModule.py b/modules/testModule.py index df8bbf4c..c3b5b5be 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -38,7 +38,6 @@ 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 @@ -65,10 +64,9 @@ 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): + def process_message(self, message: ServiceMessage) -> Response: if message.clean_content == "s, send a long message": if not is_bot_dev(message.author): return Response( @@ -358,7 +356,7 @@ def __str__(self): return "TestModule" @property - def test_cases(self): + def test_cases(self) -> list[IntegrationTest]: return [ self.create_integration_test( test_message=prompt, expected_response=self.TEST_MODE_RESPONSE_MESSAGE diff --git a/servicemodules/discordConstants.py b/servicemodules/discordConstants.py index 00329da5..9147b633 100644 --- a/servicemodules/discordConstants.py +++ b/servicemodules/discordConstants.py @@ -37,6 +37,7 @@ book_club_channel_id: str = {"production": "929823603766222919", "development": "-2"}[ENVIRONMENT_TYPE] dialogues_with_stampy_channel_id: str = {"production": "1013976966564675644", "development": "-2"}[ENVIRONMENT_TYPE] meta_channel_id: str = {"production": "741332060031156296", "development": "817518780509847562"}[ENVIRONMENT_TYPE] +meta_editing_channel_id: str = {"production": "1088468403406258196", "development": "1123312639016181810"}[ENVIRONMENT_TYPE] archive_category_id: str = {"production": "929823542818766948", "development": "-2"}[ENVIRONMENT_TYPE] voice_context_channel_id: str = {"production": "810261871029387275", "development": "-2"}[ENVIRONMENT_TYPE] @@ -53,8 +54,7 @@ automatic_question_channel_id = general_channel_id # TODO: should this be ai_safety_questions_channel_id? -stampy_control_channel_ids: tuple[str, ...] = (test_channel_id, stampy_dev_priv_channel_id, stampy_dev_channel_id, - talk_to_stampy_channel_id, bot_owner_dms_id) +stampy_control_channel_ids: tuple[str, ...] = (test_channel_id, stampy_dev_priv_channel_id, stampy_dev_channel_id, talk_to_stampy_channel_id, bot_owner_dms_id) #################################################### @@ -63,7 +63,6 @@ bot_admin_role_id: str = {"production": "819898114823159819", "development": "948709263461711923"}[ENVIRONMENT_TYPE] bot_dev_role_id: str = {"production": "736247946676535438", "development": "817518998148087858"}[ENVIRONMENT_TYPE] -can_invite_role_id: str = {"production": "791424708973035540", "development": "-99"}[ENVIRONMENT_TYPE] member_role_id: str = {"production": "945033781818040391", "development": "947463614841901117"}[ENVIRONMENT_TYPE] # pretty sure can-invite is deprecated, but putting it here for completeness diff --git a/test/test_help.py b/test/test_help.py new file mode 100644 index 00000000..130a5298 --- /dev/null +++ b/test/test_help.py @@ -0,0 +1,10 @@ +from pathlib import Path +from unittest import TestCase + +from utilities.help_utils import build_help_md + + +class TestHelp(TestCase): + def test_build_help_md(self): + help_md = build_help_md(Path("modules")) + self.assertGreater(len(help_md), 100) diff --git a/utilities/help_utils.py b/utilities/help_utils.py index cfc4819b..81a2568c 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -1,10 +1,11 @@ from __future__ import annotations from dataclasses import dataclass +import os +from pathlib import Path import re -from typing import Literal, Optional, overload - -Format = Literal["markdown", "discord"] +from textwrap import dedent +from typing import Optional, overload @dataclass(frozen=True) @@ -127,7 +128,7 @@ def from_docstring_segment(cls, segment: str) -> CommandHelp: # TODO: improve assert ( len(lines) >= 3 - ), "Must have at least a name (1), a description (2), and an example (3)" + ), f"Must have at least a name (1), a description (2), and an example (3);\n{lines=}" 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 @@ -211,3 +212,62 @@ def _name_match(self, msg_text: str) -> Optional[str]: for name in self.all_names: if re.search(rf"(? str: + modules_with_docstrings = load_modules_with_docstrings(modules_dir) + helps = [] + for module_name, docstring in sorted( + modules_with_docstrings.items(), key=lambda x: x[0].casefold() + ): + help = ModuleHelp.from_docstring(module_name, docstring) + if not help.empty: + helps.append(help.get_module_help(markdown=True)) + return HELP_MD_HEADER + "\n\n".join(helps) + + +def load_modules_with_docstrings(modules_dir: Path) -> dict[str, str]: + modules_with_docstrings = {} + for fname in os.listdir(modules_dir): + if fname.startswith("_") or fname == "module.py": + continue + with open(modules_dir / 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 + + +HELP_MD_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[str]: + 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 + + +_re_module_name = re.compile(r"(?<=class\s)\w+(?=\(Module\):)", re.I) + + +def extract_module_name(code: str) -> Optional[str]: + if match := _re_module_name.search(code): + return match.group() diff --git a/utilities/time_utils.py b/utilities/time_utils.py new file mode 100644 index 00000000..9654257c --- /dev/null +++ b/utilities/time_utils.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from dateutil.relativedelta import relativedelta, MO + +DEFAULT_DATE = datetime(year=2022, month=1, day=1) + + +def round_to_minute(dt: datetime) -> datetime: + return dt.replace(second=0, microsecond=0) + + +def adjust_date(date_str: object) -> datetime: + """If the date is not empty string, parse it. + Otherwise, assign `DEFAULT_DATE`. + """ + if not isinstance(date_str, str) or not date_str.strip(): + return DEFAULT_DATE + return round_to_minute(datetime.fromisoformat(date_str.split("T")[0])) + + +def get_last_monday() -> datetime: + today = datetime.now() + last_monday = today + relativedelta(weekday=MO(-1)) + return last_monday.replace(hour=8, minute=0, second=0, microsecond=0)