From 63c7dd368d30bec8fd363909f6978fff959e71ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 19:42:20 +0200 Subject: [PATCH 01/26] added how to add help to Module --- utilities/help_utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/utilities/help_utils.py b/utilities/help_utils.py index cfc4819b..a5d60220 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -19,6 +19,12 @@ class ModuleHelp: 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. + + ### How to add `help` to `Module` + + ```py + self.help = ModuleHelp.from_docstring(self.class_name, __doc__) + ``` """ module_name: str From 04fd7e8bc7cdb088190008480990027abf095e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 19:42:58 +0200 Subject: [PATCH 02/26] fixed types --- modules/testModule.py | 4 ++-- utilities/help_utils.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/modules/testModule.py b/modules/testModule.py index df8bbf4c..be911c6e 100644 --- a/modules/testModule.py +++ b/modules/testModule.py @@ -68,7 +68,7 @@ def __init__(self): 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 +358,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/utilities/help_utils.py b/utilities/help_utils.py index a5d60220..aea25113 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -2,9 +2,7 @@ from dataclasses import dataclass import re -from typing import Literal, Optional, overload - -Format = Literal["markdown", "discord"] +from typing import Optional, overload @dataclass(frozen=True) From e5a3e7b330f3df4a2f44cd50a6128b7070df0489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 19:46:00 +0200 Subject: [PATCH 03/26] fixed QuestionSetter docstring --- modules/question_setter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/question_setter.py b/modules/question_setter.py index d9030eba..3e8eef05 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 From ce9cac0d044a9009b62dfee38aabd2a15b1e644a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 20:01:43 +0200 Subject: [PATCH 04/26] added doc_last_edited to coda api --- api/coda.py | 13 +------------ api/utilities/coda_utils.py | 3 +++ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/api/coda.py b/api/coda.py index a4eac3d7..dada746a 100644 --- a/api/coda.py +++ b/api/coda.py @@ -57,18 +57,7 @@ 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", - ] - ) + self.questions_df = pd.DataFrame(columns=QuestionRow.__required_keys__) if is_in_testing_mode(): return diff --git a/api/utilities/coda_utils.py b/api/utilities/coda_utils.py index 93783a1e..89233b41 100644 --- a/api/utilities/coda_utils.py +++ b/api/utilities/coda_utils.py @@ -29,6 +29,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 ] @@ -39,6 +40,7 @@ def parse_question_row(row: Row) -> QuestionRow: "status": status, "tags": tags, "last_asked_on_discord": last_asked_on_discord, + "doc_last_edited": doc_last_edited, "alternate_phrasings": alternate_phrasings, "row": row, } @@ -63,6 +65,7 @@ class QuestionRow(TypedDict): status: str tags: list[str] last_asked_on_discord: datetime + doc_last_edited: datetime alternate_phrasings: list[str] row: Row From ef723ba66f5f05ef5ffd3c6d1dbce9fa7271a522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Fri, 23 Jun 2023 20:51:04 +0200 Subject: [PATCH 05/26] fixed parsing dates into question cache --- api/coda.py | 6 ++++-- api/utilities/coda_utils.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/api/coda.py b/api/coda.py index dada746a..e07134f1 100644 --- a/api/coda.py +++ b/api/coda.py @@ -57,8 +57,10 @@ 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=QuestionRow.__required_keys__) - + # fmt:off + #pylint:disable=no-member + self.questions_df = pd.DataFrame(columns=QuestionRow.__required_keys__) # type:ignore + # fmt:on if is_in_testing_mode(): return diff --git a/api/utilities/coda_utils.py b/api/utilities/coda_utils.py index 89233b41..6dd33fdb 100644 --- a/api/utilities/coda_utils.py +++ b/api/utilities/coda_utils.py @@ -6,16 +6,20 @@ from codaio import Cell, Row -DEFAULT_DATE = datetime(1, 1, 1, 0) +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: str) -> datetime: - """If date is in isoformat, parse it. - Otherwise, assign earliest date possible. + """If the date is not empty string, parse it. + Otherwise, assign `DEFAULT_DATE`. """ if not date_str: return DEFAULT_DATE - return datetime.fromisoformat(date_str.split("T")[0]) + return round_to_minute(datetime.fromisoformat(date_str.split("T")[0])) def parse_question_row(row: Row) -> QuestionRow: From ca208049f9485ddb11b49f0d60f0750626cd3825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Sat, 24 Jun 2023 09:38:44 +0200 Subject: [PATCH 06/26] moved build_help_md to help_utils, added test --- build_help.py | 61 ++--------------------------------------- test/test_help.py | 10 +++++++ utilities/help_utils.py | 60 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 58 deletions(-) create mode 100644 test/test_help.py 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/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 aea25113..15b9f0ac 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -1,7 +1,10 @@ from __future__ import annotations from dataclasses import dataclass +import os +from pathlib import Path import re +from textwrap import dedent from typing import Optional, overload @@ -215,3 +218,60 @@ 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 modules_with_docstrings.items(): + 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() From 1b3c39f0b9218e4a17ee527a8136b4449f02a7fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Tue, 27 Jun 2023 14:15:51 +0200 Subject: [PATCH 07/26] added NOT_ROB_SERVER --- README.md | 1 + 1 file changed, 1 insertion(+) 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): From 0efa80252db24eba40a932c9117dd6ab2f0044df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Tue, 27 Jun 2023 15:38:06 +0200 Subject: [PATCH 08/26] implemented autoposting abandoned questions --- api/utilities/coda_utils.py | 6 + config.py | 6 +- modules/questions.py | 215 ++++++++++++++++++++++------- servicemodules/discordConstants.py | 1 - 4 files changed, 179 insertions(+), 49 deletions(-) diff --git a/api/utilities/coda_utils.py b/api/utilities/coda_utils.py index 6dd33fdb..eba43716 100644 --- a/api/utilities/coda_utils.py +++ b/api/utilities/coda_utils.py @@ -94,3 +94,9 @@ class QuestionRow(TypedDict): "duplicated": "Duplicate", "published": "Live on site", } + +REVIEW_STATUSES: set[QuestionStatus] = { + "Bulletpoint sketch", + "In progress", + "In review", +} diff --git a/config.py b/config.py index 5bfb64e1..7e626a8a 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, Union, cast, get_args, overload import dotenv from structlog import get_logger @@ -227,6 +227,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/modules/questions.py b/modules/questions.py index c1a02c2c..e8a22fe1 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -23,22 +23,27 @@ """ 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 dateutil.relativedelta import relativedelta, MO from discord import Thread from dotenv import load_dotenv +import pandas as pd from api.coda import ( CodaAPI, 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, + stampy_dev_priv_channel_id, +) from modules.module import Module, Response from utilities.help_utils import ModuleHelp from utilities.utilities import ( @@ -63,8 +68,6 @@ class Questions(Module): - AUTOPOST_QUESTION_INTERVAL = timedelta(hours=6) - @staticmethod def is_available() -> bool: return coda_api_token is not None and not is_in_testing_mode() @@ -82,11 +85,39 @@ def __init__(self) -> None: self.help = ModuleHelp.from_docstring(self.class_name, __doc__) self.coda_api = CodaAPI.get_instance() + ################### + # Autoposting # + ################### + + # How often Stampy posts random not started questions to `#general` + self.not_started_question_autopost_interval = timedelta(hours=6) + # Time when last question was posted - self.last_posted_time: datetime = ( - datetime.now() - self.AUTOPOST_QUESTION_INTERVAL / 2 + self.last_question_posted_dt = ( + datetime.now() - self.not_started_question_autopost_interval / 2 ) - # regexes + + # Was the last question that was posted, a not started question autoposted + self.last_question_posted_was_not_started_autoposted = False + + # Date of last autopost of abandoned question + self.last_abandoned_autopost_date: date = get_last_monday().date() + + # Max number of abandoned questions to be autoposted + self.abandoned_autopost_limit: int = 5 + + 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_abandoned(): + await self.autopost_abandoned() + + ############### + # 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() @@ -324,8 +344,8 @@ async def cb_post_questions( self.coda_api.update_question_last_asked_date(q, current_time) # update caches - self.last_posted_time = current_time - self.last_question_autoposted = False + self.last_question_posted_dt = current_time + self.last_question_posted_was_not_started_autoposted = False # if there is exactly one question, remember its ID if len(questions) == 1: @@ -337,48 +357,143 @@ 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_question_posted_dt + < datetime.now() - self.not_started_question_autopost_interval + and not self.last_question_posted_was_not_started_autoposted + ) - # query for questions with status "Not started" and not tagged as "Stampy" - questions_df_filtered = self.coda_api.questions_df.query( - "status == 'Not started'" + async def autopost_not_started(self) -> None: + """Choose a random question from the oldest not started questions + and post to the `#general` channel + """ + self.log.info( + self.class_name, + msg="Autoposting a not started question to #general channel", + dt=datetime.now(), ) - 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 + if questions_df.empty: + self.log.info( + self.class_name, + msg='Found no questions with status "Not started" without tag "Stampy"', + ) + return + question = cast( QuestionRow, random.choice( - get_least_recently_asked_on_discord(questions_df_filtered).to_dict( + get_least_recently_asked_on_discord(questions_df).to_dict( orient="records" - ), + ) ), ) - # update in coda - current_time = datetime.now() - self.coda_api.update_question_last_asked_date(question, current_time) + channel = cast(Thread, self.utils.client.get_channel(int(general_channel_id))) + self.last_question_posted_was_not_started_autoposted = True - # update caches - self.coda_api.last_question_id = question["id"] - self.last_posted_time = current_time - self.last_question_autoposted = True + await self.post_questions_to_channel([question], channel) + + def is_time_for_autopost_abandoned(self) -> bool: + today = date.today() + return today.weekday() == 0 and self.last_abandoned_autopost_date != today + + async def autopost_abandoned(self) -> None: + """Post up to a specified number of questions to a #TODO channel""" + + self.log.info( + self.class_name, msg="Autoposting abandoned questions to `#general`" + ) + self.last_abandoned_autopost_date = date.today() + _week_ago = datetime.now() - timedelta(days=7) + questions_df = self.coda_api.questions_df.sort_values( + by=["last_asked_on_discord", "doc_last_edited"] + ).query("doc_last_edited < @_week_ago") + questions_df = questions_df[ + questions_df["status"].map(lambda status: status in REVIEW_STATUSES) + ].head(self.abandoned_autopost_limit) + + if questions_df.empty: + self.log.info( + self.class_name, + msg=f"Found no questions with status from {REVIEW_STATUSES}", + ) + return + + # TODO: decide on channel + channel = cast(Thread, self.utils.client.get_channel(int(general_channel_id))) + questions = cast(list[QuestionRow], questions_df.to_dict(orient="records")) - # log self.log.info( self.class_name, - msg="Posting a random, least recent, not started question to #general", + msg=f"Posting {len(questions)} abandoned questions to channel", + channel_name=channel.name, ) + await self.post_questions_to_channel(questions, channel) + + async def post_questions_to_channel( + self, + questions: list[QuestionRow], + channel: Thread, # TODO: check if this type is correct + ) -> 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). + """ + current_time = datetime.now() + for q in questions: + self.coda_api.update_question_last_asked_date(q, current_time) + await channel.send(make_post_question_message(q)) + if len(questions) == 1: + self.coda_api.last_question_id = questions[0]["id"] + self.last_question_posted_dt = current_time + # get channel #general + + # query for questions with status "Not started" and not tagged as "Stampy" + + # update in coda + # current_time = datetime.now() + # self.coda_api.update_question_last_asked_date(question, current_time) + + # update caches + # self.coda_api.last_question_id = question["id"] + # self.last_posted_time = current_time + # self.last_question_posted_random_autoposted = True + + # log #TODO + # self.log.info( + # self.class_name, + # msg="Posting a random, least recent, not started question to #general", + # ) + # send to channel - await channel.send(make_post_question_message(question)) + # await channel.send(make_post_question_message(question)) + + async def post_stagnant_questions( + self, _event_type, stagnant_questions_df: pd.DataFrame + ) -> None: + """#TODO docstring explanation wtf""" + # TODO: add comments like in the above method + # TODO: merge this and the method above into one method? + channel = cast( + Thread, self.utils.client.get_channel(int(stampy_dev_priv_channel_id)) + ) + questions = cast( + list[QuestionRow], stagnant_questions_df.to_dict(orient="records") + ) + current_time = datetime.now() + for question in questions: + self.coda_api.update_question_last_asked_date(question, current_time) + self.last_question_posted_dt = current_time + self.last_question_posted_was_not_started_autoposted = True + self.log.info( + self.class_name, msg=f"Posting {len(questions)} stagnant questions" + ) # TODO: better msg ######################### # Get question info # @@ -574,3 +689,9 @@ def make_status_and_tag_response_text( if tag: return f" tagged as `{tag}`" return "" + + +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) diff --git a/servicemodules/discordConstants.py b/servicemodules/discordConstants.py index 00329da5..2ebc0f0f 100644 --- a/servicemodules/discordConstants.py +++ b/servicemodules/discordConstants.py @@ -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 From a76b155e5aafa4fee5f4a39113220a8202c561c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Tue, 27 Jun 2023 19:34:16 +0200 Subject: [PATCH 09/26] cleaned up post_questions_to_channel, added prefix_msg --- modules/questions.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index e8a22fe1..cda70909 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -440,39 +440,21 @@ async def post_questions_to_channel( self, questions: list[QuestionRow], channel: Thread, # TODO: check if this type is correct + prefix_msg: str = "", ) -> 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). """ current_time = datetime.now() + if prefix_msg: + await channel.send(prefix_msg) for q in questions: self.coda_api.update_question_last_asked_date(q, current_time) await channel.send(make_post_question_message(q)) if len(questions) == 1: self.coda_api.last_question_id = questions[0]["id"] self.last_question_posted_dt = current_time - # get channel #general - - # query for questions with status "Not started" and not tagged as "Stampy" - - # update in coda - # current_time = datetime.now() - # self.coda_api.update_question_last_asked_date(question, current_time) - - # update caches - # self.coda_api.last_question_id = question["id"] - # self.last_posted_time = current_time - # self.last_question_posted_random_autoposted = True - - # log #TODO - # self.log.info( - # self.class_name, - # msg="Posting a random, least recent, not started question to #general", - # ) - - # send to channel - # await channel.send(make_post_question_message(question)) async def post_stagnant_questions( self, _event_type, stagnant_questions_df: pd.DataFrame From 81fe72bcf2212820f68df049d41c8c7daa824bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 10:24:13 +0200 Subject: [PATCH 10/26] fixed typing in config --- config.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/config.py b/config.py index 7e626a8a..3aec6153 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,5 @@ import os -from typing import Literal, TypeVar, Union, cast, get_args, overload +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") From e2701e6d0ad94de90e0482f0768aa739a15ec011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 10:27:17 +0200 Subject: [PATCH 11/26] time utils etc --- api/coda.py | 14 +- api/utilities/coda_utils.py | 16 +-- modules/questions.py | 220 +++++++++++++++++------------ servicemodules/discordConstants.py | 4 +- utilities/time_utils.py | 24 ++++ 5 files changed, 166 insertions(+), 112 deletions(-) create mode 100644 utilities/time_utils.py diff --git a/api/coda.py b/api/coda.py index e07134f1..c836fbbb 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: @@ -221,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() @@ -410,8 +409,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 @@ -540,6 +538,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: diff --git a/api/utilities/coda_utils.py b/api/utilities/coda_utils.py index eba43716..b03af53c 100644 --- a/api/utilities/coda_utils.py +++ b/api/utilities/coda_utils.py @@ -5,21 +5,7 @@ from codaio import Cell, Row - -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: str) -> datetime: - """If the date is not empty string, parse it. - Otherwise, assign `DEFAULT_DATE`. - """ - if not date_str: - return DEFAULT_DATE - return round_to_minute(datetime.fromisoformat(date_str.split("T")[0])) +from utilities.time_utils import adjust_date def parse_question_row(row: Row) -> QuestionRow: diff --git a/modules/questions.py b/modules/questions.py index cda70909..3b5efc7a 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -28,10 +28,8 @@ import re from typing import cast, Optional -from dateutil.relativedelta import relativedelta, MO -from discord import Thread +from discord.channel import TextChannel from dotenv import load_dotenv -import pandas as pd from api.coda import ( CodaAPI, @@ -42,7 +40,7 @@ from config import coda_api_token, is_rob_server from servicemodules.discordConstants import ( general_channel_id, - stampy_dev_priv_channel_id, + meta_editing_channel_id, ) from modules.module import Module, Response from utilities.help_utils import ModuleHelp @@ -52,6 +50,7 @@ pformat_to_codeblock, ) from utilities.serviceutils import ServiceMessage +from utilities.time_utils import get_last_monday if coda_api_token is not None: @@ -68,6 +67,11 @@ class Questions(Module): + AUTOPOST_NOT_STARTED_MSG_PREFIX = ( + "Whoever starts working on this question is gonna get a BIG STAMP from me!" + ) + AUTOPOST_STAGNANT_MSG_PREFIX = "Whoever picks up working on some of these questions is gonna get a BIG STAMP from me!" + @staticmethod def is_available() -> bool: return coda_api_token is not None and not is_in_testing_mode() @@ -92,19 +96,21 @@ def __init__(self) -> None: # How often Stampy posts random not started questions to `#general` self.not_started_question_autopost_interval = timedelta(hours=6) - # Time when last question was posted + # Time when last question was posted #TODO: probably deprecate self.last_question_posted_dt = ( datetime.now() - self.not_started_question_autopost_interval / 2 ) - # Was the last question that was posted, a not started question autoposted - self.last_question_posted_was_not_started_autoposted = False + # Time when last question was autoposted + self.last_not_started_autopost_dt = ( + datetime.now() - self.not_started_question_autopost_interval / 2 + ) # Date of last autopost of abandoned question self.last_abandoned_autopost_date: date = get_last_monday().date() # Max number of abandoned questions to be autoposted - self.abandoned_autopost_limit: int = 5 + self.abandoned_autopost_limit: int = 3 if is_rob_server: @self.utils.client.event # fmt:skip @@ -146,6 +152,10 @@ async def on_socket_event_type(_event_type) -> None: ) def process_message(self, message: ServiceMessage) -> Response: + if message.clean_content == "a": + return Response(confidence=20, callback=self.autopost_abandoned) + if message.clean_content == "q": + return Response(confidence=20, callback=self.autopost_not_started) if not (text := self.is_at_me(message)): return Response() if text == "hardreload questions": @@ -345,7 +355,8 @@ async def cb_post_questions( # update caches self.last_question_posted_dt = current_time - self.last_question_posted_was_not_started_autoposted = False + # TODO: probably deprecate also + self.last_question_posted_was_started_autoposted = False # if there is exactly one question, remember its ID if len(questions) == 1: @@ -359,15 +370,28 @@ async def cb_post_questions( def is_time_for_autopost_not_started(self) -> bool: return ( - self.last_question_posted_dt + self.last_not_started_autopost_dt < datetime.now() - self.not_started_question_autopost_interval - and not self.last_question_posted_was_not_started_autoposted ) - async def autopost_not_started(self) -> None: - """Choose a random question from the oldest not started questions - and post to the `#general` channel - """ + 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) -> Response: + """Choose a random question from the oldest not started questions and post to `#general` channel""" + if await self.last_msg_in_general_was_autoposted(): + self.log.info( + self.class_name, + msg="Last message in channel #general was automatically posted not started question -> omitting autoposting", + ) + return Response(confidence=10) + self.log.info( self.class_name, msg="Autoposting a not started question to #general channel", @@ -382,100 +406,109 @@ async def autopost_not_started(self) -> None: self.class_name, msg='Found no questions with status "Not started" without tag "Stampy"', ) - return + return Response(confidence=10) - question = cast( - QuestionRow, - random.choice( - get_least_recently_asked_on_discord(questions_df).to_dict( - orient="records" - ) - ), + question = random.choice( + self.coda_api.q_df_to_rows( + get_least_recently_asked_on_discord(questions_df) + ) + ) + + self.log.info( + self.class_name, + msg='Posting a random question with status "Not started" to #general', + ) + + channel = cast( + TextChannel, self.utils.client.get_channel(int(general_channel_id)) ) + self.last_question_posted_was_started_autoposted = True - channel = cast(Thread, self.utils.client.get_channel(int(general_channel_id))) - self.last_question_posted_was_not_started_autoposted = True + current_time = datetime.now() + self.last_question_posted_dt = current_time - await self.post_questions_to_channel([question], channel) + msg = self.AUTOPOST_NOT_STARTED_MSG_PREFIX + "\n\n" + msg += make_post_question_message(question) + self.coda_api.update_question_last_asked_date(question, current_time) + self.coda_api.last_question_id = question["id"] + await channel.send(msg) + return Response(confidence=10) def is_time_for_autopost_abandoned(self) -> bool: - today = date.today() - return today.weekday() == 0 and self.last_abandoned_autopost_date != today + 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_abandoned_autopost_date + != now.date() # Wasn't posted today yet + ) - async def autopost_abandoned(self) -> None: - """Post up to a specified number of questions to a #TODO channel""" + 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_abandoned(self) -> Response: + """Post up to a specified number of questions to #meta-editing channel""" + self.last_abandoned_autopost_date = date.today() + + if await self.last_msg_in_meta_editing_was_autoposted(): + self.log.info( + self.class_name, + msg="Last message in channel #meta-editing was automatically posted abandoned question(s) -> omitting autoposting", + ) + return Response(confidence=10) self.log.info( - self.class_name, msg="Autoposting abandoned questions to `#general`" + self.class_name, msg="Autoposting abandoned questions to #general" ) self.last_abandoned_autopost_date = date.today() _week_ago = datetime.now() - timedelta(days=7) - questions_df = self.coda_api.questions_df.sort_values( - by=["last_asked_on_discord", "doc_last_edited"] - ).query("doc_last_edited < @_week_ago") - questions_df = questions_df[ - questions_df["status"].map(lambda status: status in REVIEW_STATUSES) - ].head(self.abandoned_autopost_limit) + questions_df = self.coda_api.questions_df.query("doc_last_edited < @_week_ago") + limit = random.randint(1, self.abandoned_autopost_limit) + questions_df = ( + questions_df[ + questions_df["status"].map(lambda status: status in REVIEW_STATUSES) + ] + .sort_values(["last_asked_on_discord", "doc_last_edited"]) + .head(limit) + ) if questions_df.empty: self.log.info( self.class_name, msg=f"Found no questions with status from {REVIEW_STATUSES}", ) - return + return Response(confidence=10) - # TODO: decide on channel - channel = cast(Thread, self.utils.client.get_channel(int(general_channel_id))) - questions = cast(list[QuestionRow], questions_df.to_dict(orient="records")) + channel = cast( + TextChannel, self.utils.client.get_channel(int(meta_editing_channel_id)) + ) + questions = self.coda_api.q_df_to_rows(questions_df) self.log.info( self.class_name, - msg=f"Posting {len(questions)} abandoned questions to channel", - channel_name=channel.name, + msg=f"Posting {len(questions)} abandoned questions to channel #meta-editing", ) - await self.post_questions_to_channel(questions, channel) - - async def post_questions_to_channel( - self, - questions: list[QuestionRow], - channel: Thread, # TODO: check if this type is correct - prefix_msg: str = "", - ) -> 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). - """ current_time = datetime.now() - if prefix_msg: - await channel.send(prefix_msg) + msg = self.AUTOPOST_STAGNANT_MSG_PREFIX + "\n\n" for q in questions: self.coda_api.update_question_last_asked_date(q, current_time) - await channel.send(make_post_question_message(q)) + msg += ( + make_post_question_message( + q, with_status=True, with_doc_last_edited=True + ) + + "\n" + ) if len(questions) == 1: self.coda_api.last_question_id = questions[0]["id"] - self.last_question_posted_dt = current_time - - async def post_stagnant_questions( - self, _event_type, stagnant_questions_df: pd.DataFrame - ) -> None: - """#TODO docstring explanation wtf""" - # TODO: add comments like in the above method - # TODO: merge this and the method above into one method? - channel = cast( - Thread, self.utils.client.get_channel(int(stampy_dev_priv_channel_id)) - ) - questions = cast( - list[QuestionRow], stagnant_questions_df.to_dict(orient="records") - ) - current_time = datetime.now() - for question in questions: - self.coda_api.update_question_last_asked_date(question, current_time) - self.last_question_posted_dt = current_time - self.last_question_posted_was_not_started_autoposted = True - self.log.info( - self.class_name, msg=f"Posting {len(questions)} stagnant questions" - ) # TODO: better msg + await channel.send(msg) + return Response(confidence=10) ######################### # Get question info # @@ -648,15 +681,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( @@ -671,9 +719,3 @@ def make_status_and_tag_response_text( if tag: return f" tagged as `{tag}`" return "" - - -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) diff --git a/servicemodules/discordConstants.py b/servicemodules/discordConstants.py index 2ebc0f0f..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) #################################################### 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) From 988aedc916a07b953753e35d69b4ab4e89ee67f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 10:41:56 +0200 Subject: [PATCH 12/26] removed redundant attributes from Questions, fixed control flow, logging, and comments --- modules/questions.py | 71 ++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 42 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index 3b5efc7a..042284ca 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -96,11 +96,6 @@ def __init__(self) -> None: # How often Stampy posts random not started questions to `#general` self.not_started_question_autopost_interval = timedelta(hours=6) - # Time when last question was posted #TODO: probably deprecate - self.last_question_posted_dt = ( - datetime.now() - self.not_started_question_autopost_interval / 2 - ) - # Time when last question was autoposted self.last_not_started_autopost_dt = ( datetime.now() - self.not_started_question_autopost_interval / 2 @@ -353,11 +348,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_question_posted_dt = current_time - # TODO: probably deprecate also - self.last_question_posted_was_started_autoposted = False - # if there is exactly one question, remember its ID if len(questions) == 1: self.coda_api.last_question_id = questions[0]["id"] @@ -388,14 +378,13 @@ async def autopost_not_started(self) -> Response: if await self.last_msg_in_general_was_autoposted(): self.log.info( self.class_name, - msg="Last message in channel #general was automatically posted not started question -> omitting autoposting", + msg="Last message in #general was an autoposted question with status `Not started` -> skipping autoposting", ) return Response(confidence=10) self.log.info( self.class_name, - msg="Autoposting a not started question to #general channel", - dt=datetime.now(), + msg="Autoposting a question with status `Not started` to #general", ) questions_df = self.coda_api.questions_df.query("status == 'Not started'") questions_df = questions_df[ @@ -404,7 +393,7 @@ async def autopost_not_started(self) -> Response: if questions_df.empty: self.log.info( self.class_name, - msg='Found no questions with status "Not started" without tag "Stampy"', + msg='Found no questions with status `Not started` without tag "Stampy"', ) return Response(confidence=10) @@ -416,21 +405,18 @@ async def autopost_not_started(self) -> Response: self.log.info( self.class_name, - msg='Posting a random question with status "Not started" to #general', + msg="Posting a random question with status `Not started` to #general", ) channel = cast( TextChannel, self.utils.client.get_channel(int(general_channel_id)) ) - self.last_question_posted_was_started_autoposted = True + msg = f"{self.AUTOPOST_NOT_STARTED_MSG_PREFIX}\n\n{make_post_question_message(question)}" current_time = datetime.now() - self.last_question_posted_dt = current_time - - msg = self.AUTOPOST_NOT_STARTED_MSG_PREFIX + "\n\n" - msg += make_post_question_message(question) self.coda_api.update_question_last_asked_date(question, current_time) self.coda_api.last_question_id = question["id"] + await channel.send(msg) return Response(confidence=10) @@ -453,60 +439,61 @@ async def last_msg_in_meta_editing_was_autoposted(self) -> bool: return False async def autopost_abandoned(self) -> Response: - """Post up to a specified number of questions to #meta-editing channel""" - self.last_abandoned_autopost_date = date.today() + """Post up to a specified number of questions to #meta-editing channel. + Returns response for ease of debugging with callbacks. + """ if await self.last_msg_in_meta_editing_was_autoposted(): self.log.info( self.class_name, - msg="Last message in channel #meta-editing was automatically posted abandoned question(s) -> omitting autoposting", + msg="Last message in `#meta-editing` was one or more autoposted abandoned question(s) -> skipping autoposting", ) return Response(confidence=10) self.log.info( self.class_name, msg="Autoposting abandoned questions to #general" ) - self.last_abandoned_autopost_date = date.today() - _week_ago = datetime.now() - timedelta(days=7) - questions_df = self.coda_api.questions_df.query("doc_last_edited < @_week_ago") - limit = random.randint(1, self.abandoned_autopost_limit) + + today = date.today() + self.last_abandoned_autopost_date = today + _week_ago = today - timedelta(days=7) + question_limit = random.randint(1, self.abandoned_autopost_limit) + + questions_df = self.coda_api.questions_df.query("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(limit) + .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}", + msg=f"Found no questions with status from {REVIEW_STATUSES} with docs edited one week ago or earlier", ) return Response(confidence=10) - channel = cast( - TextChannel, self.utils.client.get_channel(int(meta_editing_channel_id)) - ) questions = self.coda_api.q_df_to_rows(questions_df) self.log.info( self.class_name, - msg=f"Posting {len(questions)} abandoned questions to channel #meta-editing", + msg=f"Posting {len(questions)} abandoned questions to #meta-editing", ) + 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: self.coda_api.update_question_last_asked_date(q, current_time) - msg += ( - make_post_question_message( - q, with_status=True, with_doc_last_edited=True - ) - + "\n" - ) - if len(questions) == 1: - self.coda_api.last_question_id = questions[0]["id"] + msg += f"{make_post_question_message(q, with_status=True, with_doc_last_edited=True)}\n" + await channel.send(msg) return Response(confidence=10) From 551605b84debc62c22e9a4812327d46931872986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 13:43:23 +0200 Subject: [PATCH 13/26] fixed bugs related to reloading questions and updated to doc_last_edited --- api/coda.py | 25 +++++++++++++------------ api/utilities/coda_utils.py | 4 ++-- modules/questions.py | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/api/coda.py b/api/coda.py index c836fbbb..0aa69528 100644 --- a/api/coda.py +++ b/api/coda.py @@ -161,15 +161,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 @@ -557,6 +559,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") + _oldest_date = questions["last_asked_on_discord"].min() + return questions.query("last_asked_on_discord == @_oldest_date") diff --git a/api/utilities/coda_utils.py b/api/utilities/coda_utils.py index b03af53c..8b4da91e 100644 --- a/api/utilities/coda_utils.py +++ b/api/utilities/coda_utils.py @@ -29,9 +29,9 @@ def parse_question_row(row: Row) -> QuestionRow: "url": url, "status": status, "tags": tags, + "alternate_phrasings": alternate_phrasings, "last_asked_on_discord": last_asked_on_discord, "doc_last_edited": doc_last_edited, - "alternate_phrasings": alternate_phrasings, "row": row, } @@ -54,9 +54,9 @@ class QuestionRow(TypedDict): url: str status: str tags: list[str] + alternate_phrasings: list[str] last_asked_on_discord: datetime doc_last_edited: datetime - alternate_phrasings: list[str] row: Row diff --git a/modules/questions.py b/modules/questions.py index 042284ca..a2668522 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -464,7 +464,7 @@ async def autopost_abandoned(self) -> Response: questions_df[ questions_df["status"].map(lambda status: status in REVIEW_STATUSES) ] - .sort_values(("last_asked_on_discord", "doc_last_edited")) + .sort_values(["last_asked_on_discord", "doc_last_edited"]) .head(question_limit) ) From c755de20d62f7685b30caba56a403e2e862da9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 13:54:58 +0200 Subject: [PATCH 14/26] switched to editing questions dataframe with .at instead of .loc --- api/coda.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/api/coda.py b/api/coda.py index 0aa69528..65e6bcec 100644 --- a/api/coda.py +++ b/api/coda.py @@ -268,7 +268,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 @@ -282,7 +282,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)""" @@ -298,8 +298,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 @@ -310,10 +310,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"] ############### From bdcf953f064849c72055eb5123186cb7b1b04818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 13:57:56 +0200 Subject: [PATCH 15/26] fixed order of operations in autopost_abandoned --- modules/questions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/questions.py b/modules/questions.py index a2668522..761d7212 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -491,8 +491,8 @@ async def autopost_abandoned(self) -> Response: current_time = datetime.now() msg = self.AUTOPOST_STAGNANT_MSG_PREFIX + "\n\n" for q in questions: - self.coda_api.update_question_last_asked_date(q, current_time) 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) return Response(confidence=10) From d75014312102221c394ad3757c24c0cf22a17247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 14:15:09 +0200 Subject: [PATCH 16/26] removed backdoors for testing autoposting --- modules/questions.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index 761d7212..8a421bb2 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -147,10 +147,6 @@ async def on_socket_event_type(_event_type) -> None: ) def process_message(self, message: ServiceMessage) -> Response: - if message.clean_content == "a": - return Response(confidence=20, callback=self.autopost_abandoned) - if message.clean_content == "q": - return Response(confidence=20, callback=self.autopost_not_started) if not (text := self.is_at_me(message)): return Response() if text == "hardreload questions": @@ -414,6 +410,7 @@ async def autopost_not_started(self) -> Response: msg = f"{self.AUTOPOST_NOT_STARTED_MSG_PREFIX}\n\n{make_post_question_message(question)}" current_time = datetime.now() + self.last_not_started_autopost_dt = current_time self.coda_api.update_question_last_asked_date(question, current_time) self.coda_api.last_question_id = question["id"] From 9660c9c7addc3168f8035b71205db2fc04e464c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 14:20:21 +0200 Subject: [PATCH 17/26] added description of autoposting to Questions docstring --- modules/questions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/questions.py b/modules/questions.py index 8a421bb2..81d53437 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. 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. #TODO add this thingy to checking +2. 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 From 5f9c1987cc0a797e7deaa489fe01a269ab36dc58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 14:38:47 +0200 Subject: [PATCH 18/26] changed to recording times of attempted autoposts instead of finished --- modules/questions.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index 81d53437..23fcd3d3 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -1,7 +1,7 @@ """ Querying question database. This module is also responsible for automatically posting questions coda questions to channels -1. 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. #TODO add this thingy to checking +1. 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. 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 @@ -99,13 +99,13 @@ def __init__(self) -> None: # How often Stampy posts random not started questions to `#general` self.not_started_question_autopost_interval = timedelta(hours=6) - # Time when last question was autoposted - self.last_not_started_autopost_dt = ( + # 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 ) - # Date of last autopost of abandoned question - self.last_abandoned_autopost_date: date = get_last_monday().date() + # Date of last (attempted) autopost of abandoned question(s) + self.last_abandoned_autopost_attempt_date: date = get_last_monday().date() # Max number of abandoned questions to be autoposted self.abandoned_autopost_limit: int = 3 @@ -359,7 +359,7 @@ async def cb_post_questions( def is_time_for_autopost_not_started(self) -> bool: return ( - self.last_not_started_autopost_dt + self.last_not_started_autopost_attempt_dt < datetime.now() - self.not_started_question_autopost_interval ) @@ -373,7 +373,13 @@ async def last_msg_in_general_was_autoposted(self) -> bool: return False async def autopost_not_started(self) -> Response: - """Choose a random question from the oldest not started questions and post to `#general` channel""" + """Choose a random question from the oldest not started questions and post to `#general` channel + + Returns `Response` for ease of debugging with callbacks. + """ + current_time = datetime.now() + self.last_not_started_autopost_attempt_dt = current_time + if await self.last_msg_in_general_was_autoposted(): self.log.info( self.class_name, @@ -412,8 +418,6 @@ async def autopost_not_started(self) -> Response: ) msg = f"{self.AUTOPOST_NOT_STARTED_MSG_PREFIX}\n\n{make_post_question_message(question)}" - current_time = datetime.now() - self.last_not_started_autopost_dt = current_time self.coda_api.update_question_last_asked_date(question, current_time) self.coda_api.last_question_id = question["id"] @@ -425,7 +429,7 @@ def is_time_for_autopost_abandoned(self) -> bool: 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_abandoned_autopost_date + and self.last_abandoned_autopost_attempt_date != now.date() # Wasn't posted today yet ) @@ -441,8 +445,11 @@ async def last_msg_in_meta_editing_was_autoposted(self) -> bool: async def autopost_abandoned(self) -> Response: """Post up to a specified number of questions to #meta-editing channel. - Returns response for ease of debugging with callbacks. + Returns `Response` for ease of debugging with callbacks. """ + today = date.today() + self.last_abandoned_autopost_attempt_date = today + if await self.last_msg_in_meta_editing_was_autoposted(): self.log.info( self.class_name, @@ -454,8 +461,6 @@ async def autopost_abandoned(self) -> Response: self.class_name, msg="Autoposting abandoned questions to #general" ) - today = date.today() - self.last_abandoned_autopost_date = today _week_ago = today - timedelta(days=7) question_limit = random.randint(1, self.abandoned_autopost_limit) From 4b168e1c843a8494c658eb321c5234f44a8156b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 14:45:48 +0200 Subject: [PATCH 19/26] slightly prunned autopost logging --- modules/questions.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index 23fcd3d3..c6e84cb2 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -408,11 +408,6 @@ async def autopost_not_started(self) -> Response: ) ) - self.log.info( - self.class_name, - msg="Posting a random question with status `Not started` to #general", - ) - channel = cast( TextChannel, self.utils.client.get_channel(int(general_channel_id)) ) @@ -457,10 +452,6 @@ async def autopost_abandoned(self) -> Response: ) return Response(confidence=10) - self.log.info( - self.class_name, msg="Autoposting abandoned questions to #general" - ) - _week_ago = today - timedelta(days=7) question_limit = random.randint(1, self.abandoned_autopost_limit) From 5cce124331e206da9c95509876bb03b11fc84bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 14:49:32 +0200 Subject: [PATCH 20/26] renamed abandoned to WIP --- modules/questions.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index c6e84cb2..6ae044b8 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -1,8 +1,8 @@ """ Querying question database. This module is also responsible for automatically posting questions coda questions to channels -1. 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. 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. +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 @@ -104,19 +104,19 @@ def __init__(self) -> None: datetime.now() - self.not_started_question_autopost_interval / 2 ) - # Date of last (attempted) autopost of abandoned question(s) - self.last_abandoned_autopost_attempt_date: date = get_last_monday().date() + # Date of last (attempted) autopost of WIP question(s) + self.last_wip_autopost_attempt_date: date = get_last_monday().date() - # Max number of abandoned questions to be autoposted - self.abandoned_autopost_limit: int = 3 + # 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_abandoned(): - await self.autopost_abandoned() + if self.is_time_for_autopost_wip(): + await self.autopost_wip() ############### # Regexes # @@ -419,12 +419,12 @@ async def autopost_not_started(self) -> Response: await channel.send(msg) return Response(confidence=10) - def is_time_for_autopost_abandoned(self) -> bool: + 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_abandoned_autopost_attempt_date + and self.last_wip_autopost_attempt_date != now.date() # Wasn't posted today yet ) @@ -437,23 +437,24 @@ async def last_msg_in_meta_editing_was_autoposted(self) -> bool: return True return False - async def autopost_abandoned(self) -> Response: - """Post up to a specified number of questions to #meta-editing channel. + async def autopost_wip(self) -> Response: + """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. Returns `Response` for ease of debugging with callbacks. """ today = date.today() - self.last_abandoned_autopost_attempt_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 abandoned question(s) -> skipping autoposting", + msg="Last message in `#meta-editing` was one or more autoposted WIP question(s) -> skipping autoposting", ) return Response(confidence=10) _week_ago = today - timedelta(days=7) - question_limit = random.randint(1, self.abandoned_autopost_limit) + question_limit = random.randint(1, self.wip_autopost_limit) questions_df = self.coda_api.questions_df.query("doc_last_edited <= @_week_ago") questions_df = ( @@ -475,7 +476,7 @@ async def autopost_abandoned(self) -> Response: self.log.info( self.class_name, - msg=f"Posting {len(questions)} abandoned questions to #meta-editing", + msg=f"Posting {len(questions)} WIP questions to #meta-editing", ) if len(questions) == 1: From e24264ff06558fa469200d19fb94f6c1dacc8742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 15:20:16 +0200 Subject: [PATCH 21/26] fixed help building and docstring in silly --- database/Factoids.db | Bin 8192 -> 8192 bytes modules/Silly.py | 20 +++++++++++--------- utilities/help_utils.py | 6 ++++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/database/Factoids.db b/database/Factoids.db index 99997049c678099cebb39b840cc33ad942d4492c..402b9c43f5a58b7c2afffc35fbee09dc8006dc02 100644 GIT binary patch delta 91 zcmZp0XmFSy&B!!S#+i|6W5N=CHb(x#4E%>T3kvMyx3y+uV~`g&H%v;*OHs&6%uOvy vEy$@1$w*aj%g@QlFVD `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/utilities/help_utils.py b/utilities/help_utils.py index 15b9f0ac..ea518599 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -134,7 +134,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 @@ -228,7 +228,9 @@ def _name_match(self, msg_text: str) -> Optional[str]: def build_help_md(modules_dir: Path) -> str: modules_with_docstrings = load_modules_with_docstrings(modules_dir) helps = [] - for module_name, docstring in modules_with_docstrings.items(): + 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)) From 74ed3f01c31c3cf7eb567469ba1d16848f6b4690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 20:46:38 +0200 Subject: [PATCH 22/26] simplified warning disabling --- api/coda.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/coda.py b/api/coda.py index 65e6bcec..92281cd7 100644 --- a/api/coda.py +++ b/api/coda.py @@ -57,10 +57,8 @@ def __init__(self): self.class_name = "Coda API" self.log = get_logger() self.last_question_id: Optional[str] = None - # fmt:off - #pylint:disable=no-member - self.questions_df = pd.DataFrame(columns=QuestionRow.__required_keys__) # type:ignore - # fmt:on + # pylint:disable=no-member + self.questions_df = pd.DataFrame(columns=list(QuestionRow.__required_keys__)) # fmt:skip if is_in_testing_mode(): return From 618ac598b3df436aa66fb461de51f8a025db2f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 21:09:24 +0200 Subject: [PATCH 23/26] removed @-syntax from pandas.DataFrame.query --- api/coda.py | 6 +++--- modules/questions.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/api/coda.py b/api/coda.py index 92281cd7..07ab965f 100644 --- a/api/coda.py +++ b/api/coda.py @@ -386,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) @@ -555,5 +555,5 @@ def get_least_recently_asked_on_discord( questions: pd.DataFrame, ) -> pd.DataFrame: """Get all questions with oldest date and shuffle them""" - _oldest_date = questions["last_asked_on_discord"].min() - return questions.query("last_asked_on_discord == @_oldest_date") + oldest_date = questions["last_asked_on_discord"].min() + return questions.query(f"last_asked_on_discord == '{oldest_date}'") diff --git a/modules/questions.py b/modules/questions.py index 6ae044b8..ffe9e8c9 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -267,7 +267,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) @@ -453,10 +453,12 @@ async def autopost_wip(self) -> Response: ) return Response(confidence=10) - _week_ago = today - timedelta(days=7) + week_ago = today - timedelta(days=7) question_limit = random.randint(1, self.wip_autopost_limit) - questions_df = self.coda_api.questions_df.query("doc_last_edited <= @_week_ago") + 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) From 0eea983393b5b5129099b1cc5fc39bbbe5878f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 21:13:08 +0200 Subject: [PATCH 24/26] changed AUTOPOST messages --- modules/questions.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index ffe9e8c9..1d532f64 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -70,10 +70,8 @@ class Questions(Module): - AUTOPOST_NOT_STARTED_MSG_PREFIX = ( - "Whoever starts working on this question is gonna get a BIG STAMP from me!" - ) - AUTOPOST_STAGNANT_MSG_PREFIX = "Whoever picks up working on some of these questions is gonna get a BIG STAMP from me!" + 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: From ed47cbac906cac2a0b8cab620030e933d7c94e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 21:16:05 +0200 Subject: [PATCH 25/26] changed return type of autopost methods to None --- modules/questions.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/modules/questions.py b/modules/questions.py index 1d532f64..1f8586db 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -370,11 +370,8 @@ async def last_msg_in_general_was_autoposted(self) -> bool: return True return False - async def autopost_not_started(self) -> Response: - """Choose a random question from the oldest not started questions and post to `#general` channel - - Returns `Response` for ease of debugging with callbacks. - """ + 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 @@ -383,7 +380,7 @@ async def autopost_not_started(self) -> Response: self.class_name, msg="Last message in #general was an autoposted question with status `Not started` -> skipping autoposting", ) - return Response(confidence=10) + return self.log.info( self.class_name, @@ -398,7 +395,7 @@ async def autopost_not_started(self) -> Response: self.class_name, msg='Found no questions with status `Not started` without tag "Stampy"', ) - return Response(confidence=10) + return question = random.choice( self.coda_api.q_df_to_rows( @@ -415,7 +412,6 @@ async def autopost_not_started(self) -> Response: self.coda_api.last_question_id = question["id"] await channel.send(msg) - return Response(confidence=10) def is_time_for_autopost_wip(self) -> bool: now = datetime.now() @@ -435,12 +431,9 @@ async def last_msg_in_meta_editing_was_autoposted(self) -> bool: return True return False - async def autopost_wip(self) -> Response: + 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. - - Returns `Response` for ease of debugging with callbacks. - """ + to #meta-editing channel.""" today = date.today() self.last_wip_autopost_attempt_date = today @@ -449,7 +442,7 @@ async def autopost_wip(self) -> Response: self.class_name, msg="Last message in `#meta-editing` was one or more autoposted WIP question(s) -> skipping autoposting", ) - return Response(confidence=10) + return week_ago = today - timedelta(days=7) question_limit = random.randint(1, self.wip_autopost_limit) @@ -470,7 +463,7 @@ async def autopost_wip(self) -> Response: self.class_name, msg=f"Found no questions with status from {REVIEW_STATUSES} with docs edited one week ago or earlier", ) - return Response(confidence=10) + return questions = self.coda_api.q_df_to_rows(questions_df) @@ -492,7 +485,6 @@ async def autopost_wip(self) -> Response: self.coda_api.update_question_last_asked_date(q, current_time) await channel.send(msg) - return Response(confidence=10) ######################### # Get question info # From 2424165de8a5b1884563cbb101a1316868bcbed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Bagi=C5=84ski?= Date: Wed, 28 Jun 2023 21:32:46 +0200 Subject: [PATCH 26/26] moved defining module help to the parent Module class --- modules/HelpModule.py | 2 -- modules/module.py | 16 +++------------- modules/question_setter.py | 2 -- modules/questions.py | 2 -- modules/testModule.py | 2 -- utilities/help_utils.py | 6 ------ 6 files changed, 3 insertions(+), 27 deletions(-) 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/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 3e8eef05..30e53fe0 100644 --- a/modules/question_setter.py +++ b/modules/question_setter.py @@ -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 1f8586db..a47c6477 100644 --- a/modules/questions.py +++ b/modules/questions.py @@ -46,7 +46,6 @@ 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, @@ -87,7 +86,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() ################### diff --git a/modules/testModule.py b/modules/testModule.py index be911c6e..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,7 +64,6 @@ 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) -> Response: diff --git a/utilities/help_utils.py b/utilities/help_utils.py index ea518599..81a2568c 100644 --- a/utilities/help_utils.py +++ b/utilities/help_utils.py @@ -20,12 +20,6 @@ class ModuleHelp: 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. - - ### How to add `help` to `Module` - - ```py - self.help = ModuleHelp.from_docstring(self.class_name, __doc__) - ``` """ module_name: str